diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index d4737f4f..604241e2 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -79,7 +79,7 @@ from flask_notification_routes import notification_bp # noqa: E402 from flask_oci_routes import oci_bp # noqa: E402 from notification_manager import notification_manager # noqa: E402 import post_install_versions # noqa: E402 — Sprint 12A: detect post-install function updates -from jwt_middleware import require_auth, require_auth_or_ticket # noqa: E402 +from jwt_middleware import require_auth, require_auth_or_ticket, require_admin_scope # noqa: E402 import auth_manager # noqa: E402 # ------------------------------------------------------------------- @@ -1702,6 +1702,255 @@ def _ensure_vm_disk_refresher(): t.start() +# ─── Actions API helpers ───────────────────────────────────────────────── +# +# Backing store for POST /api/system/... and POST /api/proxmenux/... — +# transient systemd units. Doing it this way instead of `subprocess.Popen` +# gives us four properties for free: +# +# 1. Persistence: the unit outlives the Flask process, so the +# self-update path (which restarts proxmenux-monitor +# mid-install) doesn't lose track of the run. +# 2. Concurrency: systemd rejects `systemd-run --unit ` if that +# unit is already active, so we return 409 Conflict +# without needing our own flock. +# 3. Cancellable: `systemctl stop ` from CLI or DELETE from API. +# 4. State + log: `systemctl show ... --property=...` gives us the +# ActiveState / ExecMainStatus / timestamps, and the +# unit's stdout/stderr lands in the journal. +# +# Every action gets a stable unit name (one per action, not per run) so +# the GET .../status endpoint knows where to look without needing the +# client to remember an opaque run-id. + + +# Snapshot of the last terminal state for each action unit. systemd +# reclaims transient units the instant they finish (or are stopped +# explicitly); without this cache, `.../status` would jump back to +# `idle` seconds after a run ends and callers would never see the +# exit code / result they polled for. Every time `_action_state` +# observes a terminal state we snapshot it here; when a later call +# finds the unit gone from systemd, we serve the snapshot instead. +_action_last_state = {} # unit_name -> full state dict +_ACTION_TERMINAL = ('success', 'failed', 'cancelled') + + +def _action_run(unit_name, cmd_argv, description=None, extra_properties=None): + """Launch ``cmd_argv`` as a transient systemd oneshot unit. + + Returns ``(True, {})`` on success or ``(False, {"error": ..., "status": })`` + on failure. The caller is responsible for mapping the error dict to + a jsonify() + status code. + """ + if not unit_name.endswith('.service'): + unit_name = f'{unit_name}.service' + + state = _action_state(unit_name) + if state.get('state') == 'running': + return False, { + 'error': 'Action already in progress', + 'unit': unit_name, + 'state': state, + 'status': 409, + } + + # Starting a new run — clear any previous terminal snapshot so we + # don't serve stale exit-code data on the first `.../status` call + # after the new run completes. + _action_last_state.pop(unit_name, None) + + # Any previous run may have left the unit in "active (exited)" via + # RemainAfterExit — reset it so systemd will accept the fresh start. + # Silent: reset-failed no-ops when the unit isn't in a failed state, + # and stop no-ops when it isn't loaded. + try: + subprocess.run(['systemctl', 'reset-failed', unit_name], + capture_output=True, text=True, timeout=5) + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + pass + + # Type=simple: systemd treats the first process as the main one and + # follows its lifecycle — correct for long-running scripts like + # `proxmox_update.sh` (minutes) or the self-update installer. + # RemainAfterExit=yes: after the main process exits, systemd keeps + # the unit in the "active (exited)" state so `.../status` remains + # informative (exit code, timestamps, result) instead of returning + # to `idle` the instant the run finishes. Without this, transient + # units disappear on termination and callers lose the last-run info. + run_argv = [ + 'systemd-run', + '--unit', unit_name, + '--property', 'Type=simple', + '--property', 'RemainAfterExit=yes', + ] + if description: + run_argv.extend(['--description', description]) + for prop in (extra_properties or []): + run_argv.extend(['--property', prop]) + run_argv.extend(cmd_argv) + + try: + proc = subprocess.run(run_argv, capture_output=True, text=True, timeout=10) + except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e: + return False, {'error': f'systemd-run failed to launch: {e}', 'status': 500} + + if proc.returncode != 0: + return False, { + 'error': 'systemd-run refused to start the unit', + 'stderr': (proc.stderr or '').strip(), + 'status': 500, + } + return True, {} + + +def _action_state(unit_name): + """Read the state of a transient action unit. + + Returns a uniform dict — ALWAYS the same keys regardless of whether + the unit ever existed. Callers can jsonify() it directly. + + ``state`` values: + ``idle`` – never run (or fully cleaned up) + ``running`` – currently executing + ``success`` – last run finished with exit 0 + ``failed`` – last run finished with non-zero exit + ``cancelled`` – last run was stopped via DELETE / systemctl stop + ``unknown`` – systemctl responded with a shape we don't expect + """ + if not unit_name.endswith('.service'): + unit_name = f'{unit_name}.service' + + empty = { + 'unit': unit_name, + 'state': 'idle', + 'started_at': None, + 'finished_at': None, + 'exit_code': None, + 'result': None, + } + + try: + proc = subprocess.run( + ['systemctl', 'show', unit_name, + '--property=LoadState,ActiveState,SubState,Result,ExecMainStatus,ExecMainStartTimestamp,ExecMainExitTimestamp'], + capture_output=True, text=True, timeout=5, + ) + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + return empty + + if proc.returncode != 0: + return empty + + props = {} + for line in proc.stdout.splitlines(): + if '=' in line: + k, _, v = line.partition('=') + props[k] = v + + if props.get('LoadState') == 'not-found': + # systemd already reclaimed the transient unit. If we have a + # snapshot of its terminal state, serve that so the caller sees + # the exit code / result / timestamps of the last run. + snap = _action_last_state.get(unit_name) + return snap if snap else empty + + active = props.get('ActiveState', '') + sub = props.get('SubState', '') + result = props.get('Result', '') or None + + exit_code = props.get('ExecMainStatus') + try: + exit_code = int(exit_code) if exit_code not in (None, '') else None + except (TypeError, ValueError): + exit_code = None + + # Type=simple + RemainAfterExit=yes semantics: + # active/running → process still executing + # active/exited → process finished cleanly (unit persists post-exit) + # failed → process died with non-zero or was killed by signal + if active == 'active' and sub == 'running': + state = 'running' + elif active == 'active' and sub == 'exited' and (result in (None, '', 'success')): + state = 'success' + elif active == 'inactive' and result == 'success': + # No RemainAfterExit (older units) — terminated cleanly then vanished. + state = 'success' + elif result == 'canceled': + state = 'cancelled' + # SIGTERM (exit 15) is the signal systemd sends when we DELETE the + # unit or when the operator runs `systemctl stop` — treat it as + # cancelled rather than failed, so the API surfaces intent, not + # only the signal that carried it. A hard SIGKILL (137) or any + # other signal still surfaces as `failed`. + elif result == 'signal' and exit_code == 15: + state = 'cancelled' + elif active == 'failed' or result in ('exit-code', 'signal', 'core-dump', 'timeout', 'oom-kill'): + state = 'failed' + elif active in ('activating', 'deactivating') or sub == 'start': + state = 'running' + else: + state = 'unknown' + + def _ts(s): + # systemd emits either an empty string, "0" or a formatted date; + # we return it as-is (empty → None) so the frontend / client + # doesn't have to parse an ad-hoc format. + return s.strip() if s and s.strip() and s.strip() != '0' else None + + result_dict = { + 'unit': unit_name, + 'state': state, + 'started_at': _ts(props.get('ExecMainStartTimestamp', '')), + 'finished_at': _ts(props.get('ExecMainExitTimestamp', '')), + 'exit_code': exit_code, + 'result': result, + } + # Cache terminal states so we can serve them after systemd garbage- + # collects the transient unit. Running / idle states are transient + # by definition and don't need snapshotting. + if state in _ACTION_TERMINAL: + _action_last_state[unit_name] = result_dict + return result_dict + + +def _action_cancel(unit_name): + """Stop a running transient action unit. Idempotent — succeeds on + units that are not running (or don't exist). + + Preserves the started-at timestamp of the run being cancelled and + manufactures a `cancelled` snapshot into ``_action_last_state`` so + that callers polling `.../status` after the cancel see the + cancellation intent, not the empty `idle` state that systemd + exposes once transient units get reclaimed. We snapshot BEFORE + calling `systemctl stop` because systemd releases the unit the + moment the stop returns — reading state afterwards would see + `not-found`. + """ + if not unit_name.endswith('.service'): + unit_name = f'{unit_name}.service' + + pre = _action_state(unit_name) + was_active = pre.get('state') in ('running',) + + try: + subprocess.run(['systemctl', 'stop', unit_name], + capture_output=True, text=True, timeout=10) + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + return False + + if was_active: + import datetime + _action_last_state[unit_name] = { + 'unit': unit_name, + 'state': 'cancelled', + 'started_at': pre.get('started_at'), + 'finished_at': datetime.datetime.now().astimezone().strftime('%a %Y-%m-%d %H:%M:%S %Z'), + 'exit_code': 15, + 'result': 'signal', + } + return True + + def get_cached_pvesh_cluster_resources_vm(): """Get cluster VM resources with a per-process cache. @@ -18882,6 +19131,132 @@ def api_host_backups_archive_log(archive_id): }) +# ─── Actions API ───────────────────────────────────────────────────────── +# +# External-integration friendly endpoints (Home Assistant, Homepage, +# Ansible, custom dashboards) to trigger the same actions the operator +# would run from the Monitor UI or the shell menu. All mutating routes +# require a token with `full_admin` scope; the read-only `.../status` +# routes accept any authenticated caller. Every trigger returns 202 +# with the state at the moment of the call; clients poll `.../status` +# to observe progress. See `_action_run` / `_action_state` for the +# systemd-run backing that makes each of these persistent, cancellable +# and single-instance without extra plumbing. + +# System power — reboot / shutdown of the Proxmox host + +@app.route('/api/system/power/reboot', methods=['POST']) +@require_admin_scope +def api_system_power_reboot(): + """Reboot the Proxmox host. Fire-and-forget: the host will drop the + HTTP connection when the shutdown sequence starts.""" + ok, err = _action_run( + 'proxmenux-action-system-power-reboot', + ['/bin/systemctl', 'reboot'], + description='ProxMenux action: reboot host', + ) + if not ok: + return jsonify({k: v for k, v in err.items() if k != 'status'}), err.get('status', 500) + return jsonify(_action_state('proxmenux-action-system-power-reboot')), 202 + + +@app.route('/api/system/power/reboot/status', methods=['GET']) +@require_auth +def api_system_power_reboot_status(): + return jsonify(_action_state('proxmenux-action-system-power-reboot')) + + +@app.route('/api/system/power/shutdown', methods=['POST']) +@require_admin_scope +def api_system_power_shutdown(): + """Power off the Proxmox host.""" + ok, err = _action_run( + 'proxmenux-action-system-power-shutdown', + ['/bin/systemctl', 'poweroff'], + description='ProxMenux action: shutdown host', + ) + if not ok: + return jsonify({k: v for k, v in err.items() if k != 'status'}), err.get('status', 500) + return jsonify(_action_state('proxmenux-action-system-power-shutdown')), 202 + + +@app.route('/api/system/power/shutdown/status', methods=['GET']) +@require_auth +def api_system_power_shutdown_status(): + return jsonify(_action_state('proxmenux-action-system-power-shutdown')) + + +# Proxmox VE update — same script the Update Now dashboard button runs + +_PVE_UPDATE_SCRIPT = '/usr/local/share/proxmenux/scripts/utilities/proxmox_update.sh' + + +@app.route('/api/system/pve-update/run', methods=['POST']) +@require_admin_scope +def api_system_pve_update_run(): + """Trigger the same safe PVE update flow the Health Monitor's + Update Now button invokes (delegates to `update-pve-safe.sh`).""" + if not os.path.exists(_PVE_UPDATE_SCRIPT): + return jsonify({'error': 'PVE update script not installed', + 'path': _PVE_UPDATE_SCRIPT}), 500 + ok, err = _action_run( + 'proxmenux-action-pve-update', + ['/bin/bash', _PVE_UPDATE_SCRIPT], + description='ProxMenux action: Proxmox VE update', + ) + if not ok: + return jsonify({k: v for k, v in err.items() if k != 'status'}), err.get('status', 500) + return jsonify(_action_state('proxmenux-action-pve-update')), 202 + + +@app.route('/api/system/pve-update/status', methods=['GET']) +@require_auth +def api_system_pve_update_status(): + return jsonify(_action_state('proxmenux-action-pve-update')) + + +@app.route('/api/system/pve-update', methods=['DELETE']) +@require_admin_scope +def api_system_pve_update_cancel(): + """Cancel a PVE update run in progress. No-op if no run is active.""" + _action_cancel('proxmenux-action-pve-update') + return jsonify(_action_state('proxmenux-action-pve-update')) + + +# ProxMenux self-update — pipes the canonical one-line installer. +# Runs inside a systemd unit so it survives the proxmenux-monitor +# service restart the installer performs mid-way. + +_PROXMENUX_INSTALLER_URL = 'https://raw.githubusercontent.com/MacRimi/ProxMenux/main/install_proxmenux.sh' + + +@app.route('/api/proxmenux/self-update/run', methods=['POST']) +@require_admin_scope +def api_proxmenux_self_update_run(): + """Update ProxMenux itself by piping the canonical installer. + + The installer restarts `proxmenux-monitor.service` mid-way; because + the run lives in its own transient systemd unit (not a subprocess + of this Flask worker), the update completes even though the Monitor + process that accepted the call dies during the restart. Clients + can poll `.../status` to observe completion. + """ + ok, err = _action_run( + 'proxmenux-action-self-update', + ['/bin/bash', '-c', f'wget -qLO - {_PROXMENUX_INSTALLER_URL} | bash'], + description='ProxMenux action: self-update', + ) + if not ok: + return jsonify({k: v for k, v in err.items() if k != 'status'}), err.get('status', 500) + return jsonify(_action_state('proxmenux-action-self-update')), 202 + + +@app.route('/api/proxmenux/self-update/status', methods=['GET']) +@require_auth +def api_proxmenux_self_update_status(): + return jsonify(_action_state('proxmenux-action-self-update')) + + if __name__ == '__main__': import sys import logging diff --git a/web/app/[locale]/docs/monitor/api/page.tsx b/web/app/[locale]/docs/monitor/api/page.tsx index f7d1506f..d650c60d 100644 --- a/web/app/[locale]/docs/monitor/api/page.tsx +++ b/web/app/[locale]/docs/monitor/api/page.tsx @@ -63,6 +63,7 @@ export default async function MonitorApiPage({ auth: { rows: EndpointRow[]; items: string[] } conventions: { items: string[] } system: { rows: EndpointRow[] } + actions: { rows: EndpointRow[] } health: { rows: EndpointRow[] } storage: { rows: EndpointRow[] } network: { rows: EndpointRow[] } @@ -81,6 +82,7 @@ export default async function MonitorApiPage({ const authItems = api.auth.items const conventionsItems = api.conventions.items const systemRows = api.system.rows + const actionsRows = api.actions.rows const healthRows = api.health.rows const storageRows = api.storage.rows const networkRows = api.network.rows @@ -181,6 +183,29 @@ export default async function MonitorApiPage({

{t("system.heading")}

{endpointTable(systemRows, "system.rows")} +

{t("actions.heading")}

+

{t.rich("actions.intro", { code })}

+ +

{t("actions.shapeTitle")}

+

{t("actions.shapeIntro")}

+ + +

{t("actions.concurrencyTitle")}

+

{t.rich("actions.concurrencyBody", { code })}

+ + {endpointTable(actionsRows, "actions.rows")} + +

{t("actions.syncVsAsyncTitle")}

+

{t.rich("actions.syncVsAsyncBody", { code })}

+ +

{t("actions.curlTitle")}

+

{t("actions.curlBody")}

+ + +

{t("actions.haTitle")}

+

{t.rich("actions.haBody", { code })}

+ +

{t("health.heading")}

{endpointTable(healthRows, "health.rows")}

diff --git a/web/messages/en/docs/monitor/api-reference.json b/web/messages/en/docs/monitor/api-reference.json index 683bb1c8..416db038 100644 --- a/web/messages/en/docs/monitor/api-reference.json +++ b/web/messages/en/docs/monitor/api-reference.json @@ -100,6 +100,85 @@ } ] }, + "actions": { + "heading": "System actions", + "intro": "Fire-and-forget endpoints that trigger the same operations the operator would run from the Monitor UI or the shell menu — designed for Home Assistant, Homepage, Ansible, custom dashboards and any other automation that needs to reach into the host over HTTP. Every mutating route requires a token with full_admin scope; read-only read_only tokens can still poll the .../status endpoints without triggering anything. Each action returns 202 with a state snapshot; poll the matching .../status to observe progress.", + "shapeTitle": "Common response shape", + "shapeIntro": "All action endpoints return the same JSON object — one shape to parse from any client:", + "shapeCode": "{\n \"unit\": \"proxmenux-action-pve-update.service\",\n \"state\": \"idle | running | success | failed | cancelled\",\n \"started_at\": \"Sat 2026-08-01 11:01:32 CEST\",\n \"finished_at\": null,\n \"exit_code\": null,\n \"result\": null\n}", + "concurrencyTitle": "Concurrency and cancellation", + "concurrencyBody": "A second POST while a run is in flight returns 409 with the current running state. Cancel an in-flight run with a DELETE to the action's base URL; the state moves to cancelled and timestamps are preserved so the caller can still see when it was triggered and stopped. The last terminal state (success / failed / cancelled) is remembered until the next run replaces it.", + "rows": [ + { + "endpoint": "/api/system/power/reboot", + "method": "POST", + "use": "Reboot the Proxmox host. Fire-and-forget — the HTTP connection drops when systemd starts the shutdown sequence." + }, + { + "endpoint": "/api/system/power/reboot/status", + "method": "GET", + "use": "State of the last / current reboot action." + }, + { + "endpoint": "/api/system/power/shutdown", + "method": "POST", + "use": "Power off the Proxmox host. Useful as an effect wired to a UPS shutdown signal." + }, + { + "endpoint": "/api/system/power/shutdown/status", + "method": "GET", + "use": "State of the last / current shutdown action." + }, + { + "endpoint": "/api/system/pve-update/run", + "method": "POST", + "use": "Trigger the safe PVE update flow (the same one the Health Monitor's Update Now button and the shell menu run — delegates to update-pve-safe.sh)." + }, + { + "endpoint": "/api/system/pve-update/status", + "method": "GET", + "use": "State of the last / current PVE update run." + }, + { + "endpoint": "/api/system/pve-update", + "method": "DELETE", + "use": "Cancel a PVE update in progress (SIGTERM to the running script)." + }, + { + "endpoint": "/api/proxmenux/self-update/run", + "method": "POST", + "use": "Update ProxMenux itself by piping the canonical stable installer. Runs in its own systemd unit so the update completes across the proxmenux-monitor restart the installer performs." + }, + { + "endpoint": "/api/proxmenux/self-update/status", + "method": "GET", + "use": "State of the last / current ProxMenux self-update run." + }, + { + "endpoint": "/api/vms//control", + "method": "POST", + "use": "Start / stop / shutdown / reboot a VM or LXC container (the same operations the Monitor's VM & LXC modal exposes). Body: {\"action\": \"start|stop|shutdown|reboot\"}. Synchronous — returns the outcome directly with no polling needed." + }, + { + "endpoint": "/api/vms//backup", + "method": "POST", + "use": "Create a vzdump backup of a VM or LXC. Body (all optional except when the defaults don't match your storage layout): {\"storage\": \"\", \"mode\": \"snapshot|suspend|stop\", \"compress\": \"zstd|lzo|gz|none\", \"protected\": true, \"notes\": \"…\", \"notification\": \"auto|always|failure|never\", \"pbs_change_detection\": \"default|legacy|data\"}. Returns the PVE task UPID." + }, + { + "endpoint": "/api/vms//backups", + "method": "GET", + "use": "List previous backups for a VM or LXC across all reachable storages." + } + ], + "syncVsAsyncTitle": "Two flavours of action", + "syncVsAsyncBody": "System-level actions (host power, PVE update, ProxMenux self-update) can take minutes — they run in their own transient systemd unit and expose a .../status endpoint plus a DELETE to cancel. VM / LXC actions (start, stop, shutdown, reboot, backup) are fast and use the fire-and-return style the Monitor's UI already exposes: the POST returns the outcome directly. Both flavours share the same authentication model (JWT or long-lived API token).", + "curlTitle": "curl example", + "curlBody": "Fire a PVE update and poll for completion — the exact same sequence a Home Assistant automation or an Ansible playbook would run:", + "curlCode": "TOKEN=\n\ncurl -sSf -X POST \\\n -H \"Authorization: Bearer $TOKEN\" \\\n https://:8008/api/system/pve-update/run\n\nwhile true; do\n state=$(curl -sSf -H \"Authorization: Bearer $TOKEN\" \\\n https://:8008/api/system/pve-update/status | jq -r .state)\n [ \"$state\" != \"running\" ] && break\n sleep 30\ndone\necho \"final state: $state\"", + "haTitle": "Home Assistant integration snippet", + "haBody": "A rest sensor polls the state and a rest_command triggers the update. Wire both to a dashboard button and to any automation you like:", + "haCode": "# configuration.yaml\nsensor:\n - platform: rest\n resource: https://pve.local:8008/api/system/pve-update/status\n name: pve_update_state\n value_template: \"{{ value_json.state }}\"\n scan_interval: 60\n headers:\n Authorization: \"Bearer !secret proxmenux_token\"\n\nrest_command:\n pve_update:\n url: https://pve.local:8008/api/system/pve-update/run\n method: POST\n headers:\n Authorization: \"Bearer !secret proxmenux_token\"\n\n # Start VM 100 (works for LXC too — same endpoint)\n vm_100_start:\n url: https://pve.local:8008/api/vms/100/control\n method: POST\n content_type: 'application/json'\n payload: '{\"action\": \"start\"}'\n headers:\n Authorization: \"Bearer !secret proxmenux_token\"\n\n # Backup VM 100 to the 'pbs-main' storage\n vm_100_backup:\n url: https://pve.local:8008/api/vms/100/backup\n method: POST\n content_type: 'application/json'\n payload: '{\"storage\": \"pbs-main\", \"mode\": \"snapshot\", \"compress\": \"zstd\"}'\n headers:\n Authorization: \"Bearer !secret proxmenux_token\"" + }, "health": { "heading": "Health Monitor", "rows": [ diff --git a/web/messages/es/docs/monitor/api-reference.json b/web/messages/es/docs/monitor/api-reference.json index e5719633..aef3f56a 100644 --- a/web/messages/es/docs/monitor/api-reference.json +++ b/web/messages/es/docs/monitor/api-reference.json @@ -100,6 +100,85 @@ } ] }, + "actions": { + "heading": "Acciones del sistema", + "intro": "Endpoints fire-and-forget que disparan las mismas operaciones que el usuario ejecutaría desde la interfaz del Monitor o desde el menú shell — pensados para Home Assistant, Homepage, Ansible, paneles personalizados y cualquier otra automatización que necesite alcanzar al host por HTTP. Toda ruta que muta el estado requiere un token con scope full_admin; los tokens read_only pueden consultar los endpoints .../status sin disparar nada. Cada acción devuelve 202 con una instantánea del estado; el cliente hace polling al .../status correspondiente para observar el progreso.", + "shapeTitle": "Formato de respuesta común", + "shapeIntro": "Todos los endpoints de acción devuelven el mismo objeto JSON — una sola forma que parsear desde cualquier cliente:", + "shapeCode": "{\n \"unit\": \"proxmenux-action-pve-update.service\",\n \"state\": \"idle | running | success | failed | cancelled\",\n \"started_at\": \"Sat 2026-08-01 11:01:32 CEST\",\n \"finished_at\": null,\n \"exit_code\": null,\n \"result\": null\n}", + "concurrencyTitle": "Concurrencia y cancelación", + "concurrencyBody": "Un segundo POST mientras una ejecución está en curso devuelve 409 con el estado actual. Cancela una ejecución en curso con un DELETE a la URL base de la acción; el estado pasa a cancelled y los timestamps se conservan para que el cliente vea cuándo se disparó y se detuvo. El último estado terminal (success / failed / cancelled) se recuerda hasta que la siguiente ejecución lo reemplace.", + "rows": [ + { + "endpoint": "/api/system/power/reboot", + "method": "POST", + "use": "Reiniciar el host Proxmox. Fire-and-forget — la conexión HTTP cae cuando systemd inicia la secuencia de apagado." + }, + { + "endpoint": "/api/system/power/reboot/status", + "method": "GET", + "use": "Estado de la última / actual acción de reinicio." + }, + { + "endpoint": "/api/system/power/shutdown", + "method": "POST", + "use": "Apagar el host Proxmox. Útil como efecto conectado a una señal de apagado del SAI." + }, + { + "endpoint": "/api/system/power/shutdown/status", + "method": "GET", + "use": "Estado de la última / actual acción de apagado." + }, + { + "endpoint": "/api/system/pve-update/run", + "method": "POST", + "use": "Disparar el flujo seguro de actualización de PVE (el mismo que ejecuta el botón Update Now del Health Monitor y el menú shell — delega en update-pve-safe.sh)." + }, + { + "endpoint": "/api/system/pve-update/status", + "method": "GET", + "use": "Estado de la última / actual ejecución de actualización de PVE." + }, + { + "endpoint": "/api/system/pve-update", + "method": "DELETE", + "use": "Cancelar una actualización de PVE en curso (SIGTERM al script en ejecución)." + }, + { + "endpoint": "/api/proxmenux/self-update/run", + "method": "POST", + "use": "Actualizar el propio ProxMenux canalizando el instalador estable canónico. Corre en su propia unit systemd para que la actualización complete a pesar del reinicio de proxmenux-monitor que el instalador realiza." + }, + { + "endpoint": "/api/proxmenux/self-update/status", + "method": "GET", + "use": "Estado de la última / actual ejecución de self-update de ProxMenux." + }, + { + "endpoint": "/api/vms//control", + "method": "POST", + "use": "Encender / apagar / apagar limpio / reiniciar una VM o contenedor LXC (las mismas operaciones que expone la modal VM & LXC del Monitor). Body: {\"action\": \"start|stop|shutdown|reboot\"}. Síncrono — devuelve el resultado directo sin necesidad de polling." + }, + { + "endpoint": "/api/vms//backup", + "method": "POST", + "use": "Crear un backup vzdump de una VM o LXC. Body (todo opcional excepto cuando los defaults no encajan con tu layout de storage): {\"storage\": \"\", \"mode\": \"snapshot|suspend|stop\", \"compress\": \"zstd|lzo|gz|none\", \"protected\": true, \"notes\": \"…\", \"notification\": \"auto|always|failure|never\", \"pbs_change_detection\": \"default|legacy|data\"}. Devuelve el UPID de la tarea PVE." + }, + { + "endpoint": "/api/vms//backups", + "method": "GET", + "use": "Listar backups anteriores de una VM o LXC en todos los storages accesibles." + } + ], + "syncVsAsyncTitle": "Dos estilos de acción", + "syncVsAsyncBody": "Las acciones a nivel de sistema (power del host, PVE update, self-update de ProxMenux) pueden tardar minutos — corren en su propia unit systemd transitoria y exponen un endpoint .../status más un DELETE para cancelar. Las acciones VM / LXC (start, stop, shutdown, reboot, backup) son rápidas y usan el estilo fire-and-return que la UI del Monitor ya expone: el POST devuelve el resultado directo. Ambos estilos comparten el mismo modelo de autenticación (JWT o token de API de larga duración).", + "curlTitle": "Ejemplo con curl", + "curlBody": "Disparar una actualización de PVE y hacer polling hasta que termine — exactamente la misma secuencia que ejecutaría una automatización de Home Assistant o un playbook de Ansible:", + "curlCode": "TOKEN=\n\ncurl -sSf -X POST \\\n -H \"Authorization: Bearer $TOKEN\" \\\n https://:8008/api/system/pve-update/run\n\nwhile true; do\n state=$(curl -sSf -H \"Authorization: Bearer $TOKEN\" \\\n https://:8008/api/system/pve-update/status | jq -r .state)\n [ \"$state\" != \"running\" ] && break\n sleep 30\ndone\necho \"estado final: $state\"", + "haTitle": "Snippet de integración con Home Assistant", + "haBody": "Un sensor rest hace polling del estado y un rest_command dispara la actualización. Conecta ambos a un botón del dashboard y a las automatizaciones que quieras:", + "haCode": "# configuration.yaml\nsensor:\n - platform: rest\n resource: https://pve.local:8008/api/system/pve-update/status\n name: pve_update_state\n value_template: \"{{ value_json.state }}\"\n scan_interval: 60\n headers:\n Authorization: \"Bearer !secret proxmenux_token\"\n\nrest_command:\n pve_update:\n url: https://pve.local:8008/api/system/pve-update/run\n method: POST\n headers:\n Authorization: \"Bearer !secret proxmenux_token\"\n\n # Encender VM 100 (funciona igual para LXC — mismo endpoint)\n vm_100_start:\n url: https://pve.local:8008/api/vms/100/control\n method: POST\n content_type: 'application/json'\n payload: '{\"action\": \"start\"}'\n headers:\n Authorization: \"Bearer !secret proxmenux_token\"\n\n # Backup de la VM 100 al storage 'pbs-main'\n vm_100_backup:\n url: https://pve.local:8008/api/vms/100/backup\n method: POST\n content_type: 'application/json'\n payload: '{\"storage\": \"pbs-main\", \"mode\": \"snapshot\", \"compress\": \"zstd\"}'\n headers:\n Authorization: \"Bearer !secret proxmenux_token\"" + }, "health": { "heading": "Monitor de salud", "rows": [