mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-08-02 13:56:23 +00:00
update 1.2.4.1 beta - apis
This commit is contained in:
@@ -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 <name>` if that
|
||||
# unit is already active, so we return 409 Conflict
|
||||
# without needing our own flock.
|
||||
# 3. Cancellable: `systemctl stop <unit>` 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": <http>})``
|
||||
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
|
||||
|
||||
@@ -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({
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("system.heading")}</h2>
|
||||
{endpointTable(systemRows, "system.rows")}
|
||||
|
||||
<h2 id="actions" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("actions.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("actions.intro", { code })}</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("actions.shapeTitle")}</h3>
|
||||
<p className="mb-2 text-gray-800 leading-relaxed">{t("actions.shapeIntro")}</p>
|
||||
<CopyableCode code={t("actions.shapeCode")} className="my-4" />
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("actions.concurrencyTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("actions.concurrencyBody", { code })}</p>
|
||||
|
||||
{endpointTable(actionsRows, "actions.rows")}
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("actions.syncVsAsyncTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("actions.syncVsAsyncBody", { code })}</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("actions.curlTitle")}</h3>
|
||||
<p className="mb-2 text-gray-800 leading-relaxed">{t("actions.curlBody")}</p>
|
||||
<CopyableCode code={t("actions.curlCode")} className="my-4" />
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("actions.haTitle")}</h3>
|
||||
<p className="mb-2 text-gray-800 leading-relaxed">{t.rich("actions.haBody", { code })}</p>
|
||||
<CopyableCode code={t("actions.haCode")} className="my-4" />
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("health.heading")}</h2>
|
||||
{endpointTable(healthRows, "health.rows")}
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
|
||||
@@ -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 <code>full_admin</code> scope; read-only <code>read_only</code> tokens can still poll the <code>.../status</code> endpoints without triggering anything. Each action returns 202 with a state snapshot; poll the matching <code>.../status</code> 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 <code>cancelled</code> 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/<vmid>/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/<vmid>/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\": \"<pve-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/<vmid>/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 <code>.../status</code> endpoint plus a <code>DELETE</code> 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=<your full_admin token>\n\ncurl -sSf -X POST \\\n -H \"Authorization: Bearer $TOKEN\" \\\n https://<host>:8008/api/system/pve-update/run\n\nwhile true; do\n state=$(curl -sSf -H \"Authorization: Bearer $TOKEN\" \\\n https://<host>: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 <code>rest</code> sensor polls the state and a <code>rest_command</code> 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": [
|
||||
|
||||
@@ -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 <code>full_admin</code>; los tokens <code>read_only</code> pueden consultar los endpoints <code>.../status</code> sin disparar nada. Cada acción devuelve 202 con una instantánea del estado; el cliente hace polling al <code>.../status</code> 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 <code>cancelled</code> 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/<vmid>/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/<vmid>/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\": \"<pve-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/<vmid>/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 <code>.../status</code> más un <code>DELETE</code> 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=<tu token full_admin>\n\ncurl -sSf -X POST \\\n -H \"Authorization: Bearer $TOKEN\" \\\n https://<host>:8008/api/system/pve-update/run\n\nwhile true; do\n state=$(curl -sSf -H \"Authorization: Bearer $TOKEN\" \\\n https://<host>: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 <code>rest</code> hace polling del estado y un <code>rest_command</code> 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": [
|
||||
|
||||
Reference in New Issue
Block a user