mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-07-26 18:38:30 +00:00
update 1.2.2.3 beta
This commit is contained in:
Binary file not shown.
@@ -1 +1 @@
|
||||
7d9181c2adaab229a27f7002de121117a9343bdcd5465cc6cf3510de627005c3 ProxMenux-1.2.2.3-beta.AppImage
|
||||
df4fe78ec4e77e333adf069528d9df274db16810bbcb110bfc86a5b8a0dc5a42 ProxMenux-1.2.2.3-beta.AppImage
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user