update 1.2.2.3 beta

This commit is contained in:
MacRimi
2026-07-06 12:00:43 +02:00
parent a037cd15af
commit 63f82d971d
4 changed files with 107 additions and 41 deletions

View File

@@ -1 +1 @@
7d9181c2adaab229a27f7002de121117a9343bdcd5465cc6cf3510de627005c3 ProxMenux-1.2.2.3-beta.AppImage
df4fe78ec4e77e333adf069528d9df274db16810bbcb110bfc86a5b8a0dc5a42 ProxMenux-1.2.2.3-beta.AppImage

View File

@@ -346,12 +346,15 @@ const formatRunAt = (iso: string | null) => {
// `onReplaced` is called after a successful remove/replace-remove so
// the parent can reset its local pbsEncryptMode from "existing" back
// to "new" and let the Generate/Import radio unfold naturally.
function KeyfileManageInline({
reuseText,
// Bare row of three destructive keyfile actions + their confirm
// modals. Meant to be embedded inside the operator's own "change
// mode" container — this component renders no info box, no toggle,
// no Cancel link. The parent handles when to show/hide it and how
// to signal "user wants to keep things as they are".
function KeyfileActionsBar({
onReplaced,
mutateStatus,
}: {
reuseText: string
onReplaced: () => void
mutateStatus: () => Promise<unknown>
}) {
@@ -412,10 +415,7 @@ function KeyfileManageInline({
return (
<div className="space-y-2">
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/5 p-3 text-[11px] text-muted-foreground flex items-start gap-2">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400 mt-0.5 shrink-0" />
<div>{reuseText}</div>
</div>
<div className="text-[11px] font-medium text-foreground">Manage installed keyfile</div>
<div className="flex flex-wrap gap-1.5">
<Button
type="button"
@@ -531,6 +531,16 @@ export function HostBackup() {
const [viewingJobId, setViewingJobId] = useState<string | null>(null)
const [watchingManualId, setWatchingManualId] = useState<string | null>(null)
const [runningManual, setRunningManual] = useState<boolean>(false)
// Independent tracker for the most recent manual backup that's still
// running. Survives the operator closing the ManualJobWatchModal —
// the modal only drives auto-refresh while it's OPEN, so if the
// operator dismisses it before the job finishes, the archive lists
// would only refresh on the next SWR tick (30-60 s). Polling the
// job status here at 3 s and calling mutate once we see the run
// transition to completed closes that gap regardless of modal
// state. Set = single slot per launch; a fresh manual replaces the
// slot (the previous one falls back to the periodic SWR tick).
const [pendingManualJobId, setPendingManualJobId] = useState<string | null>(null)
async function confirmDeleteJob() {
if (!jobToDelete) return
@@ -563,6 +573,25 @@ export function HostBackup() {
{ refreshInterval: 30000 },
)
// Background poll for the pending manual backup, independent of the
// watch modal being open. Fires mutateArchives/mutateRemoteArchives
// as soon as the run flips to done so the operator sees the new
// entry in Available Archives without a manual refresh.
const { data: pendingManualPoll } = useSWR<JobDetail>(
pendingManualJobId ? `/api/host-backups/jobs/${encodeURIComponent(pendingManualJobId)}` : null,
fetcher,
{ refreshInterval: 3000 },
)
useEffect(() => {
const d = pendingManualPoll as JobDetail | undefined
if (pendingManualJobId && d?.last_run_at && d?.last_result) {
mutateJobs()
mutateArchives()
mutateRemoteArchives()
setPendingManualJobId(null)
}
}, [pendingManualPoll, pendingManualJobId, mutateJobs, mutateArchives, mutateRemoteArchives])
const [inspectingArchive, setInspectingArchive] = useState<UnifiedArchive | null>(null)
// Merge local archives + remote snapshots (PBS + Borg) into a single
@@ -1001,15 +1030,12 @@ export function HostBackup() {
setRunningManual(false)
mutateJobs()
setWatchingManualId(jobId)
// Refresh BOTH archive lists a few seconds after launch so
// the new backup shows up regardless of backend: local
// archives are on disk locally, PBS / Borg snapshots are
// pulled from `remote-archives`. Without the second mutate
// a Borg manual stayed invisible until the next 60s tick.
setTimeout(() => {
mutateArchives()
mutateRemoteArchives()
}, 5000)
// Register the job with the modal-independent background
// poller so the Available Archives list refreshes the
// instant the run finishes, even if the operator closes
// the watch modal before then. Replaces the old fire-and-
// forget 5 s mutate that used to miss slow backups.
setPendingManualJobId(jobId)
}}
/>
@@ -3241,13 +3267,6 @@ function CreateJobDialog({
</div>
</label>
{pbsEncrypt && pbsRecoveryStatus?.has_keyfile && (
<KeyfileManageInline
reuseText="An encryption key is already installed on this host — it will be reused for this job. Use the buttons below to change the stored passphrase, replace the key with a new one, or remove it entirely."
onReplaced={() => setPbsEncryptMode("new")}
mutateStatus={mutatePbsRecovery}
/>
)}
{pbsEncrypt && !pbsRecoveryStatus?.has_keyfile && (
<div className="space-y-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 p-3">
@@ -3322,10 +3341,6 @@ function CreateJobDialog({
{pbsEncrypt && (
<div className="pl-7 space-y-2 rounded-md border border-blue-500/30 bg-blue-500/5 p-3">
<div className="text-[11px] font-medium text-foreground flex items-center gap-1.5">
<AlertTriangle className="h-3.5 w-3.5 text-blue-400" />
Recovery passphrase (strongly recommended)
</div>
{pbsRecoveryStatus?.has_recovery && !pbsRecoveryChange ? (
/* Escrow already saved — mirror the shell: skip
the prompt unless the operator opts to change. */
@@ -3345,6 +3360,23 @@ function CreateJobDialog({
</div>
) : (
<>
{/* When the operator clicked "Change" and a keyfile
is already installed, the keyfile management
actions sit at the top of the panel. This
collapses two prior boxes (keyfile info + recovery
escrow) into one, and only exposes the destructive
buttons when the operator explicitly asks for
change. */}
{pbsRecoveryStatus?.has_keyfile && (
<>
<KeyfileActionsBar
onReplaced={() => setPbsEncryptMode("new")}
mutateStatus={mutatePbsRecovery}
/>
<div className="border-t border-blue-500/20 my-1" />
<div className="text-[11px] font-medium text-foreground">Change recovery passphrase</div>
</>
)}
<p className="text-[11px] text-muted-foreground">
With a recovery passphrase, an encrypted copy of the keyfile is uploaded to PBS with every backup. If you lose this host, you can recover the keyfile on a fresh install with just the passphrase. Without it, losing the local keyfile means the encrypted backups become unrecoverable forever.
</p>
@@ -4148,13 +4180,6 @@ function ManualBackupDialog({
</div>
</label>
{pbsEncrypt && pbsRecoveryStatus?.has_keyfile && (
<KeyfileManageInline
reuseText="An encryption key is already installed on this host — it will be reused for this backup. Use the buttons below to change the stored passphrase, replace the key with a new one, or remove it entirely."
onReplaced={() => setPbsEncryptMode("new")}
mutateStatus={mutatePbsRecovery}
/>
)}
{pbsEncrypt && !pbsRecoveryStatus?.has_keyfile && (
<div className="space-y-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 p-3">
@@ -4229,10 +4254,6 @@ function ManualBackupDialog({
{pbsEncrypt && (
<div className="pl-7 space-y-2 rounded-md border border-blue-500/30 bg-blue-500/5 p-3">
<div className="text-[11px] font-medium text-foreground flex items-center gap-1.5">
<AlertTriangle className="h-3.5 w-3.5 text-blue-400" />
Recovery passphrase (strongly recommended)
</div>
{pbsRecoveryStatus?.has_recovery && !pbsRecoveryChange ? (
<div className="flex items-start gap-2 text-[11px]">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400 mt-0.5 shrink-0" />
@@ -4250,6 +4271,16 @@ function ManualBackupDialog({
</div>
) : (
<>
{pbsRecoveryStatus?.has_keyfile && (
<>
<KeyfileActionsBar
onReplaced={() => setPbsEncryptMode("new")}
mutateStatus={mutatePbsRecovery}
/>
<div className="border-t border-blue-500/20 my-1" />
<div className="text-[11px] font-medium text-foreground">Change recovery passphrase</div>
</>
)}
<p className="text-[11px] text-muted-foreground">
With a recovery passphrase an encrypted copy of the keyfile is uploaded to PBS on every backup. Lets you recover it on a fresh host with just the passphrase.
</p>

View File

@@ -15614,17 +15614,40 @@ def api_host_backups_manual_run():
# form when they need a separate retention bucket.
pbs_backup_id = f'hostcfg-{socket.gethostname()}'
pbs_fingerprint = (payload.get('pbs_fingerprint') or '').strip()
pbs_encrypt = bool(payload.get('pbs_encrypt'))
# Encryption mode: matches the job-create endpoint exactly.
# Reads the modern `pbs_encrypt_mode` string ("none"/"new"/
# "existing") first, falls back to legacy `pbs_encrypt` bool
# only if the modern field is absent. Both dialogs on the
# front-end send the modern field — the older ManualBackup
# code path was ignoring it and silently downgrading every
# encrypted manual backup to plain because it only looked at
# the legacy `pbs_encrypt` boolean, which the front-end
# stopped sending.
pbs_encrypt_mode = (payload.get('pbs_encrypt_mode') or '').strip().lower()
if not pbs_encrypt_mode:
pbs_encrypt_mode = 'new' if bool(payload.get('pbs_encrypt')) else 'none'
if pbs_encrypt_mode not in ('none', 'new', 'existing'):
return jsonify({'error': "pbs_encrypt_mode must be 'none', 'new' or 'existing'"}), 400
lines.append(f'PBS_REPOSITORY={pbs_repo}')
lines.append(f'PBS_PASSWORD={pbs_pass}')
lines.append(f'PBS_BACKUP_ID={pbs_backup_id}')
if pbs_fingerprint:
lines.append(f'PBS_FINGERPRINT={pbs_fingerprint}')
if pbs_encrypt:
if pbs_encrypt_mode == 'new':
kf = _pbs_keyfile_get_or_create(True)
if not kf:
return jsonify({'error': 'failed to create PBS encryption keyfile'}), 500
lines.append(f'PBS_KEYFILE={kf}')
elif pbs_encrypt_mode == 'existing':
if not os.path.isfile(_PBS_KEYFILE_PATH):
return jsonify({
'error': (
"pbs_encrypt_mode='existing' but no keyfile is installed at "
f"{_PBS_KEYFILE_PATH} — import one first with "
"POST /api/host-backups/pbs-encryption/import"
),
}), 400
lines.append(f'PBS_KEYFILE={_PBS_KEYFILE_PATH}')
elif backend == 'local':
explicit = (payload.get('local_dest_dir') or '').strip()
dest_dir = explicit.rstrip('/') if explicit else _get_local_target()['effective']
@@ -16846,6 +16869,18 @@ def _run_pbs_export(task_id: str, repo: dict, snapshot: str, output_path: str):
env = {**os.environ, 'PBS_PASSWORD': pwd}
if fp:
env['PBS_FINGERPRINT'] = fp
# Load the scrypt-unlock passphrase stored during import. Without
# this env var proxmox-backup-client can't decrypt a scrypt
# keyfile at restore time and fails with "no password input
# mechanism available". kdf=none keyfiles don't need it (empty
# file → env stays empty). Matches how the scheduled runner
# resolves PBS_ENCRYPTION_PASSWORD in run_scheduled_backup.sh.
try:
if os.path.isfile(_PBS_KEYFILE_PASS_PATH) and os.path.getsize(_PBS_KEYFILE_PASS_PATH) > 0:
with open(_PBS_KEYFILE_PASS_PATH, 'r') as _pf:
env['PBS_ENCRYPTION_PASSWORD'] = _pf.read()
except OSError:
pass
# 1) Pull the .pxar archive out of the snapshot. PBS stores the
# host backup as `hostcfg.pxar.didx`; restoring it as `hostcfg.pxar`