feat: add job management dashboard and templates for creating, editing, and viewing job details
This commit is contained in:
parent
e0dd667ca8
commit
a68685b2f5
109
README.md
109
README.md
@ -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.
|
||||||
|
|||||||
54
gui_app.py
54
gui_app.py
@ -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 ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
{% 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 }}
|
{{ job.schedule_type|capitalize }}
|
||||||
{% if job.schedule_type == 'monthly' and job.monthly_day is not none %}
|
{% 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 %}
|
||||||
|
|||||||
@ -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;">📅︎</span>
|
<span style="margin-right: 4px; font-size: 11px; line-height: 1;">📅︎</span>
|
||||||
|
{% 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 }}
|
{{ 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;">▶︎</span>Run Now
|
<span style="margin-right: 4px; font-size: 11px; line-height: 1;">▶︎</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"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user