feat: add job management dashboard and templates for creating, editing, and viewing job details

This commit is contained in:
Rizqi 2026-06-26 01:19:50 +07:00
parent e0dd667ca8
commit a68685b2f5
7 changed files with 384 additions and 30 deletions

109
README.md
View File

@ -105,6 +105,115 @@ python vsphere_backup.py --host vc.example.com --user administrator@vsphere.loca
--- ---
## Manual Restore & Clone
Backups are stored in **native VMware format** (VMDK + VMX), so they can be restored directly to vCenter/ESXi without any conversion.
### Backup File Structure
```
backups/<VM_NAME>/backup-YYYYMMDDHHMMSS/
├── manifest.json ← SHA-256 checksums + metadata
├── <VM_NAME>.vmx ← VM configuration (CPU, RAM, network, etc.)
└── <datastore_name>/
└── <VM_NAME>/
├── <VM_NAME>.vmdk ← Disk descriptor (~500 bytes, plain text)
└── <VM_NAME>-flat.vmdk ← Actual disk data (full size)
```
With compression enabled, files are stored as `.vmdk.zst` / `-flat.vmdk.zst`.
### Restoring a VM (In-Place)
#### Step 1 — Decompress (if compressed)
```bash
zstd -d <VM_NAME>.vmdk.zst
zstd -d <VM_NAME>-flat.vmdk.zst
```
#### Step 2 — Verify Checksum
```bash
# Compare the output with the value in manifest.json
sha256sum <VM_NAME>-flat.vmdk
```
#### Step 3 — Upload to Datastore
**Option A — vSphere Web Client** (easiest)
1. Navigate to **Storage** → select the target datastore
2. Create or navigate to the VM folder
3. Upload the `.vmx`, `.vmdk`, and `-flat.vmdk` files
**Option B — SCP to ESXi host**
```bash
# Enable SSH on the ESXi host first, then:
scp -r ./backup-20260623020000/<datastore>/<VM_NAME>/ \
root@esxi-host:/vmfs/volumes/<datastore>/<VM_NAME>/
```
**Option C — PowerCLI**
```powershell
# Copy files to ESXi datastore via datastore browser
Copy-DatastoreItem -Item ".\*.vmdk" -Destination "[datastore1] <VM_NAME>/"
```
#### Step 4 — Register the VM
Right-click the `.vmx` file in the datastore browser → **Register VM**, or use PowerCLI:
```powershell
New-VM -VMFilePath "[datastore1] <VM_NAME>/<VM_NAME>.vmx" -VMHost "esxi-host"
```
#### Step 5 — Power On
```powershell
Start-VM "<VM_NAME>"
```
### Cloning from Backup (New VM)
To restore a backup as a **separate new VM** without affecting the original:
1. Upload files to a **new folder** on the datastore (e.g. `<VM_NAME>-clone/`)
2. Edit the `.vmx` file — change these lines to avoid UUID/MAC conflicts:
```
displayName = "<VM_NAME>-clone"
uuid.bios = "generate a new UUID"
ethernet0.generateAddress = "00:0c:29:xx:xx:xx"
```
3. Remove any snapshot references if present:
```
# Delete or comment out lines starting with:
snapshot.redoNotWithParent =
```
4. Register and power on:
```powershell
New-VM -VMFilePath "[datastore1] <VM_NAME>-clone/<VM_NAME>.vmx"
Start-VM "<VM_NAME>-clone"
```
### Best Practices
- **Keep a copy** — never restore over your only backup copy
- **Test restore quarterly** — verify backups actually work before you need them
- **Isolated network first** — always boot cloned VMs on an isolated port group to check for IP conflicts before connecting to production
- **CBT resets on clone** — the first backup of a cloned VM will be a full backup (CBT state does not carry over)
- **Snapshot cleanup** — if the backup was taken with snapshots still active, remove orphaned snapshots after restore
---
## Safety & Architecture ## Safety & Architecture
1. **Snapshot Isolation**: The backup engine creates a temporary snapshot on the target VM, downloads the locked base files (such as `.vmdk` descriptors, `-flat.vmdk` disk data, and `.vmx` configurations) directly from the Datastore HTTP gateway, and deletes the snapshot immediately afterwards. 1. **Snapshot Isolation**: The backup engine creates a temporary snapshot on the target VM, downloads the locked base files (such as `.vmdk` descriptors, `-flat.vmdk` disk data, and `.vmx` configurations) directly from the Datastore HTTP gateway, and deletes the snapshot immediately afterwards.

View File

@ -207,6 +207,37 @@ def register_scheduler_job(info):
day=day_val, day=day_val,
hour=int(hour), minute=int(minute) hour=int(hour), minute=int(minute)
) )
elif schedule_type == '3_monthly':
hour, minute = (schedule_time.split(':') + ['00'])[:2]
day_val = monthly_day
if str(day_val).isdigit():
day_val = max(1, min(28, int(day_val)))
trigger = CronTrigger(
month='*/3',
day=day_val,
hour=int(hour), minute=int(minute)
)
elif schedule_type == '6_monthly':
hour, minute = (schedule_time.split(':') + ['00'])[:2]
day_val = monthly_day
if str(day_val).isdigit():
day_val = max(1, min(28, int(day_val)))
trigger = CronTrigger(
month='*/6',
day=day_val,
hour=int(hour), minute=int(minute)
)
elif schedule_type == 'yearly':
hour, minute = (schedule_time.split(':') + ['00'])[:2]
day_val = monthly_day
if str(day_val).isdigit():
day_val = max(1, min(28, int(day_val)))
yearly_month = info.get('yearly_month', '1')
trigger = CronTrigger(
month=int(yearly_month) if str(yearly_month).isdigit() else 1,
day=day_val,
hour=int(hour), minute=int(minute)
)
elif schedule_type == 'interval': elif schedule_type == 'interval':
trigger = IntervalTrigger(hours=max(1, int(interval_hours or 24))) trigger = IntervalTrigger(hours=max(1, int(interval_hours or 24)))
@ -432,6 +463,7 @@ def job_to_display(jid, info):
'retention_type': info.get('retention_type', 'keep_all'), 'retention_type': info.get('retention_type', 'keep_all'),
'retention_value': info.get('retention_value', 5), 'retention_value': info.get('retention_value', 5),
'monthly_day': info.get('monthly_day'), 'monthly_day': info.get('monthly_day'),
'yearly_month': info.get('yearly_month'),
'weekly_day': info.get('weekly_day'), 'weekly_day': info.get('weekly_day'),
'vm_names': vm_names, 'vm_names': vm_names,
'use_cbt': info.get('use_cbt', False), 'use_cbt': info.get('use_cbt', False),
@ -708,7 +740,7 @@ def create_and_start_job(
vm_name, dest, compress, no_verify_ssl, vm_name, dest, compress, no_verify_ssl,
sftp_host, sftp_user, sftp_password, sftp_host, sftp_user, sftp_password,
schedule_type, schedule_time, weekly_day, interval_hours, schedule_type, schedule_time, weekly_day, interval_hours,
label='', disk_filter=None, monthly_day=1, label='', disk_filter=None, monthly_day=1, yearly_month=1,
retention_type='keep_all', retention_value=5, retention_type='keep_all', retention_value=5,
vm_names=None, disk_filter_map=None, use_cbt=False vm_names=None, disk_filter_map=None, use_cbt=False
): ):
@ -744,6 +776,7 @@ def create_and_start_job(
'disk_filter': disk_filter, # None = back up all disks 'disk_filter': disk_filter, # None = back up all disks
'weekly_day': weekly_day, 'weekly_day': weekly_day,
'monthly_day': monthly_day, 'monthly_day': monthly_day,
'yearly_month': yearly_month,
'interval_hours': interval_hours, 'interval_hours': interval_hours,
'retention_type': retention_type, 'retention_type': retention_type,
'retention_value': retention_value, 'retention_value': retention_value,
@ -886,6 +919,7 @@ def create_job():
monthly_day = request.form.get('monthly_day', '1') monthly_day = request.form.get('monthly_day', '1')
monthly_time = request.form.get('monthly_time_1', '02:00') monthly_time = request.form.get('monthly_time_1', '02:00')
yearly_month = request.form.get('yearly_month', '1')
interval_hrs = request.form.get('interval_hours', '24') interval_hrs = request.form.get('interval_hours', '24')
label = request.form.get('job_label', '').strip() label = request.form.get('job_label', '').strip()
@ -898,7 +932,7 @@ def create_job():
sched_time = daily_time sched_time = daily_time
elif schedule_type == 'weekly': elif schedule_type == 'weekly':
sched_time = weekly_time sched_time = weekly_time
elif schedule_type == 'monthly': elif schedule_type in ('monthly', '3_monthly', '6_monthly', 'yearly'):
sched_time = monthly_time sched_time = monthly_time
else: else:
sched_time = '' sched_time = ''
@ -934,6 +968,7 @@ def create_job():
label=label, label=label,
disk_filter=disk_filter, disk_filter=disk_filter,
monthly_day=monthly_day, monthly_day=monthly_day,
yearly_month=yearly_month,
retention_type=retention_type, retention_type=retention_type,
retention_value=retention_value, retention_value=retention_value,
use_cbt=use_cbt, use_cbt=use_cbt,
@ -955,11 +990,13 @@ def create_job():
# Sort alphabetically for the dropdown # Sort alphabetically for the dropdown
vm_list = sorted(vm_list, key=lambda v: v['name'].lower()) vm_list = sorted(vm_list, key=lambda v: v['name'].lower())
from datetime import datetime
return render_template( return render_template(
'create_job.html', 'create_job.html',
vms=vm_list, vms=vm_list,
selected_vm=selected_vm, selected_vm=selected_vm,
show_schedule=show_schedule, show_schedule=show_schedule,
current_month=datetime.now().month,
) )
@ -995,6 +1032,7 @@ def batch_jobs():
monthly_day = request.form.get('monthly_day', '1') monthly_day = request.form.get('monthly_day', '1')
monthly_time = request.form.get('monthly_time_1', '02:00') monthly_time = request.form.get('monthly_time_1', '02:00')
yearly_month = request.form.get('yearly_month', '1')
interval_hrs = request.form.get('interval_hours', '24') interval_hrs = request.form.get('interval_hours', '24')
label_prefix = request.form.get('job_label', '').strip() label_prefix = request.form.get('job_label', '').strip()
@ -1002,7 +1040,7 @@ def batch_jobs():
sched_time = daily_time sched_time = daily_time
elif schedule_type == 'weekly': elif schedule_type == 'weekly':
sched_time = weekly_time sched_time = weekly_time
elif schedule_type == 'monthly': elif schedule_type in ('monthly', '3_monthly', '6_monthly', 'yearly'):
sched_time = monthly_time sched_time = monthly_time
else: else:
sched_time = '' sched_time = ''
@ -1048,6 +1086,7 @@ def batch_jobs():
label=label, label=label,
disk_filter=None, disk_filter=None,
monthly_day=monthly_day, monthly_day=monthly_day,
yearly_month=yearly_month,
retention_type=retention_type, retention_type=retention_type,
retention_value=retention_value, retention_value=retention_value,
vm_names=vm_names, vm_names=vm_names,
@ -1066,10 +1105,12 @@ def batch_jobs():
flash('No VMs specified for batch backup.', 'danger') flash('No VMs specified for batch backup.', 'danger')
return redirect(url_for('vms')) return redirect(url_for('vms'))
from datetime import datetime
return render_template( return render_template(
'batch_job.html', 'batch_job.html',
vm_names=vm_names, vm_names=vm_names,
vms_by_name=vms_by_name, vms_by_name=vms_by_name,
current_month=datetime.now().month,
) )
@ -1279,6 +1320,7 @@ def edit_job(jobid):
monthly_day = request.form.get('monthly_day', '1') monthly_day = request.form.get('monthly_day', '1')
monthly_time = request.form.get('monthly_time_1', '02:00') monthly_time = request.form.get('monthly_time_1', '02:00')
yearly_month = request.form.get('yearly_month', '1')
interval_hrs = request.form.get('interval_hours', '24') interval_hrs = request.form.get('interval_hours', '24')
label = request.form.get('job_label', '').strip() label = request.form.get('job_label', '').strip()
@ -1286,7 +1328,7 @@ def edit_job(jobid):
sched_time = daily_time sched_time = daily_time
elif schedule_type == 'weekly': elif schedule_type == 'weekly':
sched_time = weekly_time sched_time = weekly_time
elif schedule_type == 'monthly': elif schedule_type in ('monthly', '3_monthly', '6_monthly', 'yearly'):
sched_time = monthly_time sched_time = monthly_time
else: else:
sched_time = '' sched_time = ''
@ -1319,6 +1361,7 @@ def edit_job(jobid):
info['schedule_time'] = sched_time info['schedule_time'] = sched_time
info['weekly_day'] = weekly_day info['weekly_day'] = weekly_day
info['monthly_day'] = monthly_day info['monthly_day'] = monthly_day
info['yearly_month'] = yearly_month
info['interval_hours'] = interval_hrs info['interval_hours'] = interval_hrs
# Register new schedule if applicable # Register new schedule if applicable
@ -1338,7 +1381,8 @@ def edit_job(jobid):
# GET: Display edit form # GET: Display edit form
job_disp = job_to_display(jobid, info) job_disp = job_to_display(jobid, info)
return render_template('edit_job.html', job=job_disp, raw_job=info) from datetime import datetime
return render_template('edit_job.html', job=job_disp, raw_job=info, current_month=datetime.now().month)
# ── Template filter ─────────────────────────────────────────────────────────── # ── Template filter ───────────────────────────────────────────────────────────

View File

@ -78,7 +78,7 @@
.strategy-desc { font-size: 11.5px; color: var(--text-muted); margin-top: 3px; } .strategy-desc { font-size: 11.5px; color: var(--text-muted); margin-top: 3px; }
.schedule-options { .schedule-options {
display: grid; grid-template-columns: repeat(2, 1fr); display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 10px; margin-bottom: 16px; gap: 10px; margin-bottom: 16px;
} }
.schedule-opt { .schedule-opt {
@ -366,7 +366,7 @@
Schedule Schedule
</div> </div>
<div class="section-card-body"> <div class="section-card-body">
<div class="schedule-options" style="grid-template-columns: repeat(3, 1fr);"> <div class="schedule-options">
<label class="schedule-opt selected" id="opt-now" onclick="selectSchedule('now')"> <label class="schedule-opt selected" id="opt-now" onclick="selectSchedule('now')">
<input type="radio" name="schedule_type" value="now" checked /> <input type="radio" name="schedule_type" value="now" checked />
<div> <div>
@ -407,6 +407,36 @@
<div class="schedule-opt-desc">Specific day each month</div> <div class="schedule-opt-desc">Specific day each month</div>
</div> </div>
</label> </label>
<label class="schedule-opt" id="opt-3_monthly" onclick="selectSchedule('3_monthly')">
<input type="radio" name="schedule_type" value="3_monthly" />
<div>
<div class="schedule-opt-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent);"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M8 14h4"/><path d="M8 18h2"/></svg>
</div>
<div class="schedule-opt-title">3 Monthly</div>
<div class="schedule-opt-desc">Every 3 months</div>
</div>
</label>
<label class="schedule-opt" id="opt-6_monthly" onclick="selectSchedule('6_monthly')">
<input type="radio" name="schedule_type" value="6_monthly" />
<div>
<div class="schedule-opt-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent);"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M8 14h4"/><path d="M8 18h2"/></svg>
</div>
<div class="schedule-opt-title">6 Monthly</div>
<div class="schedule-opt-desc">Every 6 months</div>
</div>
</label>
<label class="schedule-opt" id="opt-yearly" onclick="selectSchedule('yearly')">
<input type="radio" name="schedule_type" value="yearly" />
<div>
<div class="schedule-opt-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent);"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><circle cx="12" cy="14" r="2"/></svg>
</div>
<div class="schedule-opt-title">Yearly</div>
<div class="schedule-opt-desc">Once a year</div>
</div>
</label>
<label class="schedule-opt" id="opt-interval" onclick="selectSchedule('interval')"> <label class="schedule-opt" id="opt-interval" onclick="selectSchedule('interval')">
<input type="radio" name="schedule_type" value="interval" /> <input type="radio" name="schedule_type" value="interval" />
<div> <div>
@ -449,6 +479,18 @@
</div> </div>
</div> </div>
<div class="schedule-detail" id="detail-monthly"> <div class="schedule-detail" id="detail-monthly">
<div class="form-row" id="yearly_month_row" style="display:none; margin-bottom: 12px;">
<div class="form-group" style="margin:0; grid-column: span 2;">
<label class="form-label" for="yearly_month">Month of Year</label>
<select id="yearly_month" name="yearly_month" class="form-control">
{% for m in range(1, 13) %}
<option value="{{ m }}" {% if current_month == m %}selected{% endif %}>
{{ ['January','February','March','April','May','June','July','August','September','October','November','December'][m-1] }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-row" style="margin-bottom: 12px;"> <div class="form-row" style="margin-bottom: 12px;">
<div class="form-group" style="margin:0; grid-column: span 2;"> <div class="form-group" style="margin:0; grid-column: span 2;">
<label class="form-label" for="monthly_basis">Monthly Schedule Type</label> <label class="form-label" for="monthly_basis">Monthly Schedule Type</label>
@ -539,17 +581,34 @@
{% block scripts %} {% block scripts %}
<script> <script>
const ALL_SCHED = ['now','daily','weekly','monthly','interval']; const ALL_SCHED = ['now','daily','weekly','monthly','3_monthly','6_monthly','yearly','interval'];
function selectSchedule(type) { function selectSchedule(type) {
ALL_SCHED.forEach(t => { ALL_SCHED.forEach(t => {
document.getElementById('opt-' + t).classList.remove('selected'); const opt = document.getElementById('opt-' + t);
if (opt) opt.classList.remove('selected');
const d = document.getElementById('detail-' + t); const d = document.getElementById('detail-' + t);
if (d) d.classList.remove('visible'); if (d) d.classList.remove('visible');
}); });
document.getElementById('opt-' + type).classList.add('selected'); const selectedOpt = document.getElementById('opt-' + type);
document.getElementById('opt-' + type).querySelector('input').checked = true; if (selectedOpt) {
const detail = document.getElementById('detail-' + type); selectedOpt.classList.add('selected');
selectedOpt.querySelector('input').checked = true;
}
let detailId = 'detail-' + type;
if (['3_monthly', '6_monthly', 'yearly'].includes(type)) {
detailId = 'detail-monthly';
const yearlyRow = document.getElementById('yearly_month_row');
if (yearlyRow) {
yearlyRow.style.display = (type === 'yearly') ? '' : 'none';
}
} else {
const yearlyRow = document.getElementById('yearly_month_row');
if (yearlyRow) yearlyRow.style.display = 'none';
}
const detail = document.getElementById(detailId);
if (detail) detail.classList.add('visible'); if (detail) detail.classList.add('visible');
} }

View File

@ -55,7 +55,7 @@
.section-card-body { padding: 24px; } .section-card-body { padding: 24px; }
.schedule-options { .schedule-options {
display: grid; grid-template-columns: repeat(2, 1fr); display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px; margin-bottom: 20px; gap: 12px; margin-bottom: 20px;
} }
.schedule-opt { .schedule-opt {
@ -403,6 +403,39 @@
</div> </div>
</label> </label>
<label class="schedule-opt" id="opt-3_monthly" onclick="selectSchedule('3_monthly')">
<input type="radio" name="schedule_type" value="3_monthly" />
<div>
<div class="schedule-opt-icon" style="height: 22px; display: flex; align-items: center;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent);"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M8 14h4"/><path d="M8 18h2"/></svg>
</div>
<div class="schedule-opt-title">3 Monthly</div>
<div class="schedule-opt-desc">Every 3 months</div>
</div>
</label>
<label class="schedule-opt" id="opt-6_monthly" onclick="selectSchedule('6_monthly')">
<input type="radio" name="schedule_type" value="6_monthly" />
<div>
<div class="schedule-opt-icon" style="height: 22px; display: flex; align-items: center;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent);"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M8 14h4"/><path d="M8 18h2"/></svg>
</div>
<div class="schedule-opt-title">6 Monthly</div>
<div class="schedule-opt-desc">Every 6 months</div>
</div>
</label>
<label class="schedule-opt" id="opt-yearly" onclick="selectSchedule('yearly')">
<input type="radio" name="schedule_type" value="yearly" />
<div>
<div class="schedule-opt-icon" style="height: 22px; display: flex; align-items: center;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent);"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><circle cx="12" cy="14" r="2"/></svg>
</div>
<div class="schedule-opt-title">Yearly</div>
<div class="schedule-opt-desc">Once a year</div>
</div>
</label>
</div> </div>
<!-- Daily detail --> <!-- Daily detail -->
@ -453,6 +486,18 @@
<!-- Monthly detail --> <!-- Monthly detail -->
<div class="schedule-detail" id="detail-monthly"> <div class="schedule-detail" id="detail-monthly">
<div class="form-row" id="yearly_month_row" style="display:none; margin-bottom: 12px;">
<div class="form-group" style="margin:0; grid-column: span 2;">
<label class="form-label" for="yearly_month">Month of Year</label>
<select id="yearly_month" name="yearly_month" class="form-control">
{% for m in range(1, 13) %}
<option value="{{ m }}" {% if current_month == m %}selected{% endif %}>
{{ ['January','February','March','April','May','June','July','August','September','October','November','December'][m-1] }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-row" style="margin-bottom: 12px;"> <div class="form-row" style="margin-bottom: 12px;">
<div class="form-group" style="margin:0; grid-column: span 2;"> <div class="form-group" style="margin:0; grid-column: span 2;">
<label class="form-label" for="monthly_basis">Monthly Schedule Type</label> <label class="form-label" for="monthly_basis">Monthly Schedule Type</label>
@ -532,14 +577,32 @@
{% block scripts %} {% block scripts %}
<script> <script>
function selectSchedule(type) { function selectSchedule(type) {
['now','daily','weekly','monthly','interval'].forEach(t => { const allTypes = ['now','daily','weekly','monthly','3_monthly','6_monthly','yearly','interval'];
document.getElementById('opt-' + t).classList.remove('selected'); allTypes.forEach(t => {
const opt = document.getElementById('opt-' + t);
if (opt) opt.classList.remove('selected');
const d = document.getElementById('detail-' + t); const d = document.getElementById('detail-' + t);
if (d) d.classList.remove('visible'); if (d) d.classList.remove('visible');
}); });
document.getElementById('opt-' + type).classList.add('selected'); const selectedOpt = document.getElementById('opt-' + type);
document.getElementById('opt-' + type).querySelector('input').checked = true; if (selectedOpt) {
const detail = document.getElementById('detail-' + type); selectedOpt.classList.add('selected');
selectedOpt.querySelector('input').checked = true;
}
let detailId = 'detail-' + type;
if (['3_monthly', '6_monthly', 'yearly'].includes(type)) {
detailId = 'detail-monthly';
const yearlyRow = document.getElementById('yearly_month_row');
if (yearlyRow) {
yearlyRow.style.display = (type === 'yearly') ? '' : 'none';
}
} else {
const yearlyRow = document.getElementById('yearly_month_row');
if (yearlyRow) yearlyRow.style.display = 'none';
}
const detail = document.getElementById(detailId);
if (detail) detail.classList.add('visible'); if (detail) detail.classList.add('visible');
} }

View File

@ -72,7 +72,7 @@
} }
@media(min-width: 600px) { @media(min-width: 600px) {
.strategy-options { grid-template-columns: 1fr 1fr; } .strategy-options { grid-template-columns: 1fr 1fr; }
.schedule-options { grid-template-columns: repeat(5, 1fr); } .schedule-options { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); }
} }
.strategy-opt, .schedule-opt { .strategy-opt, .schedule-opt {
@ -366,6 +366,33 @@
<div class="schedule-opt-desc">Specific day each month</div> <div class="schedule-opt-desc">Specific day each month</div>
</div> </div>
</label> </label>
<label class="schedule-opt {% if job.schedule_type == '3_monthly' %}selected{% endif %}" id="opt-3_monthly" onclick="selectSchedule('3_monthly')">
<input type="radio" name="schedule_type" value="3_monthly" {% if job.schedule_type == '3_monthly' %}checked{% endif %} />
<div>
<div class="schedule-opt-icon">📆</div>
<div class="schedule-opt-title">3 Monthly</div>
<div class="schedule-opt-desc">Every 3 months</div>
</div>
</label>
<label class="schedule-opt {% if job.schedule_type == '6_monthly' %}selected{% endif %}" id="opt-6_monthly" onclick="selectSchedule('6_monthly')">
<input type="radio" name="schedule_type" value="6_monthly" {% if job.schedule_type == '6_monthly' %}checked{% endif %} />
<div>
<div class="schedule-opt-icon">📆</div>
<div class="schedule-opt-title">6 Monthly</div>
<div class="schedule-opt-desc">Every 6 months</div>
</div>
</label>
<label class="schedule-opt {% if job.schedule_type == 'yearly' %}selected{% endif %}" id="opt-yearly" onclick="selectSchedule('yearly')">
<input type="radio" name="schedule_type" value="yearly" {% if job.schedule_type == 'yearly' %}checked{% endif %} />
<div>
<div class="schedule-opt-icon">📆</div>
<div class="schedule-opt-title">Yearly</div>
<div class="schedule-opt-desc">Once a year</div>
</div>
</label>
</div> </div>
<!-- Daily detail --> <!-- Daily detail -->
@ -415,7 +442,20 @@
{% set day_str = raw_job.monthly_day|string %} {% set day_str = raw_job.monthly_day|string %}
{% set week_num_val = day_str.split(' ')[0] if is_weekday else '1st' %} {% set week_num_val = day_str.split(' ')[0] if is_weekday else '1st' %}
{% set dow_val = day_str.split(' ')[1] if is_weekday else 'sun' %} {% set dow_val = day_str.split(' ')[1] if is_weekday else 'sun' %}
<div class="schedule-detail {% if job.schedule_type == 'monthly' %}visible{% endif %}" id="detail-monthly"> <div class="schedule-detail {% if job.schedule_type in ['monthly', '3_monthly', '6_monthly', 'yearly'] %}visible{% endif %}" id="detail-monthly">
<div class="form-row" id="yearly_month_row" style="{% if job.schedule_type != 'yearly' %}display:none;{% endif %} margin-bottom: 12px;">
<div class="form-group" style="margin:0; grid-column: span 2;">
<label class="form-label" for="yearly_month">Month of Year</label>
<select id="yearly_month" name="yearly_month" class="form-control">
{% set selected_m = (raw_job.yearly_month|int) if raw_job.yearly_month else (current_month or 1) %}
{% for m in range(1, 13) %}
<option value="{{ m }}" {% if selected_m == m %}selected{% endif %}>
{{ ['January','February','March','April','May','June','July','August','September','October','November','December'][m-1] }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-row" style="margin-bottom: 12px;"> <div class="form-row" style="margin-bottom: 12px;">
<div class="form-group" style="margin:0; grid-column: span 2;"> <div class="form-group" style="margin:0; grid-column: span 2;">
<label class="form-label" for="monthly_basis">Monthly Schedule Type</label> <label class="form-label" for="monthly_basis">Monthly Schedule Type</label>
@ -433,7 +473,7 @@
</div> </div>
<div class="form-group" style="margin:0;"> <div class="form-group" style="margin:0;">
<label class="form-label" for="monthly_time_1">Time (24h)</label> <label class="form-label" for="monthly_time_1">Time (24h)</label>
<input id="monthly_time_1" class="form-control" type="time" name="monthly_time_1" value="{% if job.schedule_type == 'monthly' and not is_weekday %}{{ job.schedule_time }}{% else %}02:00{% endif %}" /> <input id="monthly_time_1" class="form-control" type="time" name="monthly_time_1" value="{% if job.schedule_type in ['monthly', '3_monthly', '6_monthly', 'yearly'] and not is_weekday %}{{ job.schedule_time }}{% else %}02:00{% endif %}" />
</div> </div>
</div> </div>
@ -462,7 +502,7 @@
</div> </div>
<div class="form-group" style="margin:0; grid-column: span 2; margin-top: 12px;"> <div class="form-group" style="margin:0; grid-column: span 2; margin-top: 12px;">
<label class="form-label" for="monthly_time_2">Time (24h)</label> <label class="form-label" for="monthly_time_2">Time (24h)</label>
<input id="monthly_time_2" class="form-control" type="time" name="monthly_time_2" value="{% if job.schedule_type == 'monthly' and is_weekday %}{{ job.schedule_time }}{% else %}02:00{% endif %}" /> <input id="monthly_time_2" class="form-control" type="time" name="monthly_time_2" value="{% if job.schedule_type in ['monthly', '3_monthly', '6_monthly', 'yearly'] and is_weekday %}{{ job.schedule_time }}{% else %}02:00{% endif %}" />
</div> </div>
</div> </div>
</div> </div>
@ -484,14 +524,32 @@
{% block scripts %} {% block scripts %}
<script> <script>
function selectSchedule(type) { function selectSchedule(type) {
['now','daily','weekly','monthly','interval'].forEach(t => { const allTypes = ['now','daily','weekly','monthly','3_monthly','6_monthly','yearly','interval'];
document.getElementById('opt-' + t).classList.remove('selected'); allTypes.forEach(t => {
const opt = document.getElementById('opt-' + t);
if (opt) opt.classList.remove('selected');
const d = document.getElementById('detail-' + t); const d = document.getElementById('detail-' + t);
if (d) d.classList.remove('visible'); if (d) d.classList.remove('visible');
}); });
document.getElementById('opt-' + type).classList.add('selected'); const selectedOpt = document.getElementById('opt-' + type);
document.getElementById('opt-' + type).querySelector('input').checked = true; if (selectedOpt) {
const detail = document.getElementById('detail-' + type); selectedOpt.classList.add('selected');
selectedOpt.querySelector('input').checked = true;
}
let detailId = 'detail-' + type;
if (['3_monthly', '6_monthly', 'yearly'].includes(type)) {
detailId = 'detail-monthly';
const yearlyRow = document.getElementById('yearly_month_row');
if (yearlyRow) {
yearlyRow.style.display = (type === 'yearly') ? '' : 'none';
}
} else {
const yearlyRow = document.getElementById('yearly_month_row');
if (yearlyRow) yearlyRow.style.display = 'none';
}
const detail = document.getElementById(detailId);
if (detail) detail.classList.add('visible'); if (detail) detail.classList.add('visible');
} }

View File

@ -206,9 +206,19 @@
<div class="detail-item-val"> <div class="detail-item-val">
{% if job.schedule_type and job.schedule_type != 'now' %} {% if job.schedule_type and job.schedule_type != 'now' %}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 6px;"><path d="M23 4v6h-6"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 6px;"><path d="M23 4v6h-6"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
{{ job.schedule_type|capitalize }} {% if job.schedule_type == '3_monthly' %}
{% if job.schedule_type == 'monthly' and job.monthly_day is not none %} 3 Monthly
{% elif job.schedule_type == '6_monthly' %}
6 Monthly
{% elif job.schedule_type == 'yearly' %}
Yearly
{% else %}
{{ job.schedule_type|capitalize }}
{% endif %}
{% if job.schedule_type in ['monthly', '3_monthly', '6_monthly'] and job.monthly_day is not none %}
(Day: {{ job.monthly_day }}) (Day: {{ job.monthly_day }})
{% elif job.schedule_type == 'yearly' and job.yearly_month is not none %}
(Month: {{ ['January','February','March','April','May','June','July','August','September','October','November','December'][job.yearly_month|int - 1] if (job.yearly_month|string).isdigit() else job.yearly_month }}, Day: {{ job.monthly_day }})
{% elif job.schedule_type == 'weekly' and job.weekly_day is not none %} {% elif job.schedule_type == 'weekly' and job.weekly_day is not none %}
(Day: {{ ['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday'][job.weekly_day|int] if (job.weekly_day|string).isdigit() else job.weekly_day }}) (Day: {{ ['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday'][job.weekly_day|int] if (job.weekly_day|string).isdigit() else job.weekly_day }})
{% endif %} {% endif %}

View File

@ -165,7 +165,15 @@
{% if job.schedule_type and job.schedule_type != 'now' %} {% if job.schedule_type and job.schedule_type != 'now' %}
<span class="schedule-tag" style="margin-bottom: 4px; display: inline-flex; align-items: center;"> <span class="schedule-tag" style="margin-bottom: 4px; display: inline-flex; align-items: center;">
<span style="margin-right: 4px; font-size: 11px; line-height: 1;">&#x1F4C5;&#xFE0E;</span> <span style="margin-right: 4px; font-size: 11px; line-height: 1;">&#x1F4C5;&#xFE0E;</span>
{{ job.schedule_type|capitalize }} {% if job.schedule_type == '3_monthly' %}
3 Monthly
{% elif job.schedule_type == '6_monthly' %}
6 Monthly
{% elif job.schedule_type == 'yearly' %}
Yearly
{% else %}
{{ job.schedule_type|capitalize }}
{% endif %}
</span> </span>
{% if job.schedule_id %} {% if job.schedule_id %}
<span class="badge badge-green" style="font-size: 10px; padding: 2px 6px; display: inline-block;">Active</span> <span class="badge badge-green" style="font-size: 10px; padding: 2px 6px; display: inline-block;">Active</span>
@ -202,6 +210,9 @@
<span style="margin-right: 4px; font-size: 11px; line-height: 1;">&#x25B6;&#xFE0E;</span>Run Now <span style="margin-right: 4px; font-size: 11px; line-height: 1;">&#x25B6;&#xFE0E;</span>Run Now
</button> </button>
</form> </form>
<a href="/job/{{ job.id }}/edit" class="btn btn-secondary btn-sm">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 4px;"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 1 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>Edit
</a>
{% endif %} {% endif %}
{% if job.status == 'running' or job.status == 'queued' %} {% if job.status == 'running' or job.status == 'queued' %}
<form method="post" action="/job/{{ job.id }}/stop" <form method="post" action="/job/{{ job.id }}/stop"