update 1.2.2.2 beta

This commit is contained in:
MacRimi
2026-06-13 19:05:00 +02:00
parent 7ea9f10d6f
commit c4447eae5e
2 changed files with 265 additions and 50 deletions

View File

@@ -29,10 +29,14 @@ import { formatStorage } from "../lib/utils"
interface BackupJob { interface BackupJob {
id: string id: string
destination: string destination: string
method: string // "local_tar" | "pbs" | "borg" | "unknown" method: string // "pbs" | "borg" | "local" | "unknown"
on_calendar: string on_calendar: string // "OnCalendar=..." for timer jobs, "attached → storage:X" for attached
retention: string retention: string // "last=5, daily=7, ..."
timer_enabled: boolean timer_enabled: boolean // legacy — only meaningful for non-attached jobs
enabled: boolean // unified state (timer for non-attached, ENABLED= for attached)
attached: boolean // PVE vzdump-attached: no own timer, trigger from hook
pve_storage: string | null // storage id the attached job listens for
profile_mode: string // "default" | "custom"
last_status: string | null last_status: string | null
next_run: string | null next_run: string | null
} }
@@ -133,11 +137,41 @@ const formatNext = (iso: string | null) => {
} }
export function HostBackup() { export function HostBackup() {
const { data: jobsResp, error: jobsErr } = useSWR<{ jobs: BackupJob[] }>( const { data: jobsResp, error: jobsErr, mutate: mutateJobs } = useSWR<{ jobs: BackupJob[] }>(
"/api/host-backups/jobs", "/api/host-backups/jobs",
fetcher, fetcher,
{ refreshInterval: 30000 }, { refreshInterval: 30000 },
) )
const [busyJobId, setBusyJobId] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
async function runJob(id: string) {
setBusyJobId(id)
setActionError(null)
try {
const r = await fetchApi(`/api/host-backups/jobs/${encodeURIComponent(id)}/run`, { method: "POST" })
if (!r.ok) throw new Error(`HTTP ${r.status}`)
mutateJobs()
} catch (e) {
setActionError(`Failed to run "${id}": ${e instanceof Error ? e.message : String(e)}`)
} finally {
setBusyJobId(null)
}
}
async function toggleJob(id: string) {
setBusyJobId(id)
setActionError(null)
try {
const r = await fetchApi(`/api/host-backups/jobs/${encodeURIComponent(id)}/toggle`, { method: "POST" })
if (!r.ok) throw new Error(`HTTP ${r.status}`)
mutateJobs()
} catch (e) {
setActionError(`Failed to toggle "${id}": ${e instanceof Error ? e.message : String(e)}`)
} finally {
setBusyJobId(null)
}
}
const { data: archivesResp, error: archivesErr } = useSWR<{ archives: BackupArchive[] }>( const { data: archivesResp, error: archivesErr } = useSWR<{ archives: BackupArchive[] }>(
"/api/host-backups/archives", "/api/host-backups/archives",
fetcher, fetcher,
@@ -181,6 +215,11 @@ export function HostBackup() {
</div> </div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
{actionError && (
<div className="mb-2 text-xs text-red-500 px-2 py-1 rounded bg-red-500/10 border border-red-500/20">
{actionError}
</div>
)}
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="text-[10px] uppercase tracking-wider text-muted-foreground border-b border-border"> <thead className="text-[10px] uppercase tracking-wider text-muted-foreground border-b border-border">
<tr> <tr>
@@ -190,17 +229,27 @@ export function HostBackup() {
<th className="text-left px-2 py-2">Schedule</th> <th className="text-left px-2 py-2">Schedule</th>
<th className="text-left px-2 py-2">Last status</th> <th className="text-left px-2 py-2">Last status</th>
<th className="text-left px-2 py-2">Next run</th> <th className="text-left px-2 py-2">Next run</th>
<th className="text-right px-2 py-2">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border/40"> <tbody className="divide-y divide-border/40">
{jobsResp.jobs.map((j) => ( {jobsResp.jobs.map((j) => {
const isBusy = busyJobId === j.id
return (
<tr key={j.id} className="text-xs"> <tr key={j.id} className="text-xs">
<td className="px-2 py-2 font-mono">{j.id}</td> <td className="px-2 py-2 font-mono">{j.id}</td>
<td className="px-2 py-2 font-mono truncate max-w-[260px]" title={j.destination}> <td className="px-2 py-2 font-mono truncate max-w-[260px]" title={j.destination}>
{j.destination || "—"} {j.destination || "—"}
</td> </td>
<td className="px-2 py-2">{j.method}</td> <td className="px-2 py-2 uppercase">{j.method}</td>
<td className="px-2 py-2 font-mono">{j.on_calendar}</td> <td className="px-2 py-2 font-mono">
{j.on_calendar}
{j.attached && (
<Badge variant="outline" className="ml-2 text-blue-400 border-blue-400/30">
attached
</Badge>
)}
</td>
<td className="px-2 py-2"> <td className="px-2 py-2">
{j.last_status ? ( {j.last_status ? (
<span className="text-xs">{j.last_status}</span> <span className="text-xs">{j.last_status}</span>
@@ -209,15 +258,51 @@ export function HostBackup() {
)} )}
</td> </td>
<td className="px-2 py-2"> <td className="px-2 py-2">
<span className="text-xs">{formatNext(j.next_run)}</span> <span className="text-xs">{j.attached ? "—" : formatNext(j.next_run)}</span>
{!j.timer_enabled && ( {!j.enabled && (
<Badge variant="outline" className="ml-2 text-amber-500 border-amber-500/30"> <Badge variant="outline" className="ml-2 text-amber-500 border-amber-500/30">
timer disabled disabled
</Badge> </Badge>
)} )}
</td> </td>
<td className="px-2 py-2">
<div className="flex items-center justify-end gap-1">
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
disabled={isBusy}
onClick={() => runJob(j.id)}
title="Run this backup job now"
>
{isBusy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<PlayCircle className="h-3.5 w-3.5" />
)}
<span className="ml-1">Run</span>
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
disabled={isBusy}
onClick={() => toggleJob(j.id)}
title={j.enabled ? "Disable this job" : "Enable this job"}
>
{isBusy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : j.enabled ? (
<XCircle className="h-3.5 w-3.5" />
) : (
<CheckCircle2 className="h-3.5 w-3.5" />
)}
<span className="ml-1">{j.enabled ? "Disable" : "Enable"}</span>
</Button>
</div>
</td>
</tr> </tr>
))} )})}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@@ -12376,39 +12376,62 @@ def _find_backup_archive_path(archive_id):
return None return None
@app.route('/api/host-backups/jobs', methods=['GET']) _JOB_ID_RE = re.compile(r'^[a-zA-Z0-9_-]+$')
@require_auth _BACKUP_RUNNER = '/usr/local/share/proxmenux/scripts/backup_restore/run_scheduled_backup.sh'
def api_host_backups_jobs():
"""List scheduled host-backup jobs created via the backup_scheduler
CLI. Each job has a .env file + systemd timer. We report on both,
plus the last-run status when available."""
import glob
jobs: list = []
try:
env_files = sorted(glob.glob(f'{_BACKUP_JOBS_DIR}/*.env'))
except OSError:
env_files = []
for env_file in env_files:
job_id = os.path.basename(env_file)[:-len('.env')]
job = _parse_job_env(env_file)
def _backup_job_summary(env_file: str) -> dict:
"""Build the UI summary for one job .env, reading the actual fields
produced by the current scheduler (BACKEND, LOCAL_DEST_DIR,
PBS_REPOSITORY, BORG_REPO, KEEP_*, PVE_STORAGE, ENABLED)."""
job_id = os.path.basename(env_file)[:-len('.env')]
job = _parse_job_env(env_file)
backend = (job.get('BACKEND') or job.get('METHOD') or 'unknown').lower()
attached = bool(job.get('PVE_STORAGE'))
pve_storage = job.get('PVE_STORAGE') or None
if backend == 'pbs':
destination = job.get('PBS_REPOSITORY') or ''
bid = job.get('PBS_BACKUP_ID')
if bid:
destination = f'{destination} :: host/{bid}'
elif backend == 'local':
destination = (job.get('LOCAL_DEST_DIR') or job.get('DEST_DIR')
or job.get('DEST') or '')
elif backend == 'borg':
destination = job.get('BORG_REPO') or ''
else:
destination = (job.get('DEST_DIR') or job.get('DEST')
or job.get('PBS_REPO') or job.get('BORG_REPO') or '')
keep_parts = []
for k_key, label in (
('KEEP_LAST', 'last'),
('KEEP_HOURLY', 'hourly'),
('KEEP_DAILY', 'daily'),
('KEEP_WEEKLY', 'weekly'),
('KEEP_MONTHLY', 'monthly'),
('KEEP_YEARLY', 'yearly'),
):
v = job.get(k_key)
if v and v != '0':
keep_parts.append(f'{label}={v}')
retention = ', '.join(keep_parts) or job.get('RETENTION') or ''
if attached:
enabled = (job.get('ENABLED', '1') == '1')
schedule = f'attached → storage:{pve_storage}' if pve_storage else 'attached'
timer_enabled = False
next_run = None
else:
timer_unit = f'proxmenux-backup-{job_id}.timer' timer_unit = f'proxmenux-backup-{job_id}.timer'
timer_enabled = subprocess.run( timer_enabled = subprocess.run(
['systemctl', 'is-enabled', '--quiet', timer_unit], ['systemctl', 'is-enabled', '--quiet', timer_unit],
capture_output=True capture_output=True
).returncode == 0 ).returncode == 0
enabled = timer_enabled
last_status = None schedule = job.get('ON_CALENDAR') or 'manual'
last_status_file = f'{_BACKUP_LOG_DIR}/{job_id}-last.status'
if os.path.exists(last_status_file):
try:
with open(last_status_file) as f:
last_status = f.read().strip()
except OSError:
pass
# Next scheduled run from systemctl list-timers
next_run = None next_run = None
try: try:
r = subprocess.run( r = subprocess.run(
@@ -12422,18 +12445,125 @@ def api_host_backups_jobs():
except (subprocess.TimeoutExpired, json.JSONDecodeError, ValueError, OSError): except (subprocess.TimeoutExpired, json.JSONDecodeError, ValueError, OSError):
pass pass
jobs.append({ last_status = None
'id': job_id, last_status_file = f'{_BACKUP_LOG_DIR}/{job_id}-last.status'
'destination': (job.get('DEST_DIR') or job.get('DEST') if os.path.exists(last_status_file):
or job.get('PBS_REPO') or job.get('BORG_REPO') or ''), try:
'method': job.get('METHOD') or 'unknown', with open(last_status_file) as f:
'on_calendar': job.get('ON_CALENDAR') or 'manual', last_status = f.read().strip()
'retention': job.get('RETENTION') or '', except OSError:
'timer_enabled': timer_enabled, pass
'last_status': last_status,
'next_run': next_run, return {
}) 'id': job_id,
return jsonify({'jobs': jobs}) 'destination': destination,
'method': backend,
'on_calendar': schedule,
'retention': retention,
'timer_enabled': timer_enabled,
'enabled': enabled,
'attached': attached,
'pve_storage': pve_storage,
'profile_mode': job.get('PROFILE_MODE') or 'default',
'last_status': last_status,
'next_run': next_run,
}
@app.route('/api/host-backups/jobs', methods=['GET'])
@require_auth
def api_host_backups_jobs():
"""List scheduled host-backup jobs created via the backup_scheduler
CLI. Reports both timer-based and PVE-attached jobs."""
import glob
try:
env_files = sorted(glob.glob(f'{_BACKUP_JOBS_DIR}/*.env'))
except OSError:
env_files = []
return jsonify({'jobs': [_backup_job_summary(f) for f in env_files]})
@app.route('/api/host-backups/jobs/<job_id>/run', methods=['POST'])
@require_auth
def api_host_backups_job_run(job_id):
"""Trigger a scheduled host backup job immediately (background).
The runner writes its own log + .status file; the UI polls /jobs to
pick up the new status."""
if not _JOB_ID_RE.match(job_id):
return jsonify({'error': 'invalid job id'}), 400
env_file = f'{_BACKUP_JOBS_DIR}/{job_id}.env'
if not os.path.exists(env_file):
return jsonify({'error': 'job not found'}), 404
if not os.path.exists(_BACKUP_RUNNER):
return jsonify({'error': 'runner script not installed'}), 500
try:
subprocess.Popen(
['bash', _BACKUP_RUNNER, job_id],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
close_fds=True,
start_new_session=True,
)
except OSError as e:
return jsonify({'error': f'failed to start: {e}'}), 500
return jsonify({'status': 'started', 'job_id': job_id}), 202
@app.route('/api/host-backups/jobs/<job_id>/toggle', methods=['POST'])
@require_auth
def api_host_backups_job_toggle(job_id):
"""Flip enabled/disabled. Attached jobs are toggled by rewriting
ENABLED= in the .env (no timer exists). Timer-based jobs use
systemctl --now enable/disable. Body may include {"enabled": bool}
to set an explicit target; otherwise the current state is inverted."""
if not _JOB_ID_RE.match(job_id):
return jsonify({'error': 'invalid job id'}), 400
env_file = f'{_BACKUP_JOBS_DIR}/{job_id}.env'
if not os.path.exists(env_file):
return jsonify({'error': 'job not found'}), 404
job = _parse_job_env(env_file)
is_attached = bool(job.get('PVE_STORAGE'))
payload = request.get_json(silent=True) or {}
target = payload.get('enabled')
if is_attached:
current = (job.get('ENABLED', '1') == '1')
new_state = (not current) if target is None else bool(target)
new_val = '1' if new_state else '0'
try:
with open(env_file, 'r') as f:
lines = f.readlines()
tmp_path = f'{env_file}.tmp.{os.getpid()}'
with open(tmp_path, 'w') as tmp:
found = False
for line in lines:
if line.startswith('ENABLED='):
tmp.write(f'ENABLED={new_val}\n')
found = True
else:
tmp.write(line)
if not found:
tmp.write(f'ENABLED={new_val}\n')
os.replace(tmp_path, env_file)
os.chmod(env_file, 0o600)
except OSError as e:
return jsonify({'error': f'could not update .env: {e}'}), 500
return jsonify({'status': 'ok', 'enabled': new_state, 'attached': True})
timer_unit = f'proxmenux-backup-{job_id}.timer'
current = subprocess.run(
['systemctl', 'is-enabled', '--quiet', timer_unit],
capture_output=True
).returncode == 0
new_state = (not current) if target is None else bool(target)
cmd = ['systemctl', '--now', 'enable' if new_state else 'disable', timer_unit]
r = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
if r.returncode != 0:
return jsonify({'error': f'systemctl failed: {r.stderr.strip()}'}), 500
return jsonify({'status': 'ok', 'enabled': new_state, 'attached': False})
_BACKUP_TAR_SUFFIXES = ('.tar', '.tar.zst', '.tar.gz') _BACKUP_TAR_SUFFIXES = ('.tar', '.tar.zst', '.tar.gz')