Merge pull request 'issue-26-host-detail-control-page' (#34) from issue-26-host-detail-control-page into master
Reviewed-on: #34
This commit was merged in pull request #34.
This commit is contained in:
@@ -241,6 +241,13 @@
|
||||
.stack { display: grid; gap: 5px; }
|
||||
.stack.spaced { margin-bottom: 14px; }
|
||||
.two-col { display: grid; gap: 18px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||
.panel-grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.panel-grid .panel { margin-bottom: 0; }
|
||||
.actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
@@ -303,6 +310,53 @@
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.record-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.record-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
.record-card-header {
|
||||
align-items: start;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.record-title {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.record-title a {
|
||||
font-weight: 750;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.record-facts {
|
||||
display: grid;
|
||||
gap: 8px 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
}
|
||||
.record-fact {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.record-fact .label {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.record-fact strong,
|
||||
.record-fact span {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.action-row,
|
||||
.activity-row,
|
||||
.schedule-row {
|
||||
@@ -370,6 +424,43 @@
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.host-control-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: minmax(280px, 1.25fr) repeat(3, minmax(220px, 1fr));
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.host-control-panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.host-control-panel > h2:first-child { margin-bottom: 0; }
|
||||
.host-control-primary {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.host-control-meta {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.host-control-meta > div {
|
||||
align-items: baseline;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
padding-top: 7px;
|
||||
}
|
||||
.host-control-meta .label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.host-control-meta strong {
|
||||
text-align: right;
|
||||
}
|
||||
.status-summary {
|
||||
align-items: center;
|
||||
@@ -639,8 +730,10 @@
|
||||
display: grid;
|
||||
}
|
||||
.page-header .actions { justify-content: flex-start; }
|
||||
.two-col { grid-template-columns: 1fr; }
|
||||
.two-col,
|
||||
.panel-grid { grid-template-columns: 1fr; }
|
||||
.dashboard-priority-grid { grid-template-columns: 1fr; }
|
||||
.host-control-grid { grid-template-columns: 1fr; }
|
||||
.schedule-row { grid-template-columns: 1fr; }
|
||||
.schedule-time { justify-items: start; text-align: left; }
|
||||
.host-card-header { display: grid; }
|
||||
|
||||
@@ -9,70 +9,8 @@
|
||||
<h1>{{ host.host }}</h1>
|
||||
<div class="page-subtitle">{{ host.address }} · {{ host.enabled|yesno:"enabled,disabled" }}</div>
|
||||
</div>
|
||||
<section class="actions" aria-label="Host actions">
|
||||
<a class="button-link" href="{% url 'edit_host_config' host.host %}">Edit config</a>
|
||||
<form method="post" action="{% url 'discover_host_snapshots' host.host %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit">Discover snapshots</button>
|
||||
</form>
|
||||
<a class="button-link" href="{% url 'host_retention_plan' host.host %}">Plan retention</a>
|
||||
<a class="button-link" href="{% url 'edit_host_schedule' host.host %}">Edit schedule</a>
|
||||
<form method="post" action="{% url 'prepare_host_directories' host.host %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="secondary">Prepare directories</button>
|
||||
</form>
|
||||
<form method="post" action="{% url 'scan_host_known_key' host.host %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="secondary">Scan SSH host key</button>
|
||||
</form>
|
||||
<form method="post" action="{% url 'run_host_preflight' host.host %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="secondary">Run connection preflight</button>
|
||||
</form>
|
||||
</section>
|
||||
</header>
|
||||
|
||||
<section class="grid" aria-label="Host summary">
|
||||
<div class="metric"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></div>
|
||||
<div class="metric"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></div>
|
||||
<div class="metric"><div class="label">Queued</div><div class="value">{{ counts.queued_runs }}</div></div>
|
||||
<div class="metric"><div class="label">Running</div><div class="value">{{ counts.running_runs }}</div></div>
|
||||
<div class="metric"><div class="label">Failed Runs</div><div class="value">{{ counts.failed_runs }}</div></div>
|
||||
<div class="metric"><div class="label">Incomplete</div><div class="value">{{ counts.incomplete_snapshots }}</div></div>
|
||||
</section>
|
||||
|
||||
<div class="two-col">
|
||||
<section class="panel">
|
||||
<h2>Config</h2>
|
||||
<div class="stack">
|
||||
<div><strong>Address:</strong> {{ host.address }}</div>
|
||||
<div><strong>Enabled:</strong> {{ host.enabled|yesno:"yes,no" }}</div>
|
||||
<div><strong>SSH key:</strong> {{ host.ssh_credential|default:"global default" }}</div>
|
||||
<div><strong>SSH:</strong> {{ host.ssh_user|default:"global" }}{% if host.ssh_port %}:{{ host.ssh_port }}{% endif %}</div>
|
||||
<div><strong>Backup source:</strong> {{ host.source_root|default:"global default" }}</div>
|
||||
<div><strong>Retention:</strong> daily {{ host.retention_daily }}, weekly {{ host.retention_weekly }}, monthly {{ host.retention_monthly }}, yearly {{ host.retention_yearly }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Schedule</h2>
|
||||
{% if schedule %}
|
||||
<div class="stack">
|
||||
<div><strong>Schedule expression:</strong> {{ schedule.cron_expr }}</div>
|
||||
<div class="muted">Evaluated by the pobsync scheduler service.</div>
|
||||
<div><strong>Enabled:</strong> {{ schedule.enabled|yesno:"yes,no" }}</div>
|
||||
<div><strong>Next run:</strong> {% if next_run_at %}{{ next_run_at|date:"Y-m-d H:i T" }} <span class="muted">{{ scheduler_timezone }}</span>{% endif %}</div>
|
||||
<div><strong>Prune:</strong> {{ schedule.prune|yesno:"yes,no" }}</div>
|
||||
<div><strong>Last status:</strong> {{ schedule.last_status|default:"" }}</div>
|
||||
<div><strong>Last started:</strong> {{ schedule.last_started_at|default:"" }}</div>
|
||||
<div><strong>Last finished:</strong> {{ schedule.last_finished_at|default:"" }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">No schedule configured.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% if retention_warning.has_warning %}
|
||||
<section class="panel highlight warning">
|
||||
<h2>Retention Warnings</h2>
|
||||
@@ -101,60 +39,137 @@
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if effective_config %}
|
||||
<section class="panel">
|
||||
<h2>Effective Config</h2>
|
||||
<div class="two-col">
|
||||
<div class="stack">
|
||||
<div><strong>Backup source:</strong> {{ effective_config.source_root }}</div>
|
||||
<div><strong>Destination subdir:</strong> {{ effective_config.destination_subdir|default:"none" }}</div>
|
||||
<div><strong>SSH:</strong> {{ effective_config.ssh.user }}@{{ host.address }}:{{ effective_config.ssh.port }}</div>
|
||||
<div><strong>SSH key:</strong> {{ effective_config.ssh.credential|default:"none selected" }}</div>
|
||||
<div><strong>SSH options:</strong> {{ effective_config.ssh.options|join:" " }}</div>
|
||||
<div><strong>Rsync binary:</strong> {{ effective_config.rsync.binary }}</div>
|
||||
<div><strong>Rsync args:</strong> {{ effective_config.rsync.args|join:" " }}</div>
|
||||
<div><strong>Timeout:</strong> {{ effective_config.rsync.timeout_seconds }}s</div>
|
||||
<div><strong>Bandwidth limit:</strong> {{ effective_config.rsync.bwlimit_kbps }} KB/s</div>
|
||||
<div>
|
||||
<strong>Retention:</strong>
|
||||
d{{ effective_config.retention.daily }}
|
||||
w{{ effective_config.retention.weekly }}
|
||||
m{{ effective_config.retention.monthly }}
|
||||
y{{ effective_config.retention.yearly }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stack">
|
||||
<div><strong>Includes:</strong> {{ effective_config.includes|length }}</div>
|
||||
{% if effective_config.includes %}
|
||||
<pre>{{ effective_config.includes|join:" " }}</pre>
|
||||
<section class="host-control-grid" aria-label="Host control workspace">
|
||||
<article class="panel host-control-panel">
|
||||
<h2>Host Status</h2>
|
||||
<div class="host-control-primary">
|
||||
<div>
|
||||
{% if host.enabled %}
|
||||
<span class="status ok">enabled</span>
|
||||
{% else %}
|
||||
<div class="muted">No include rules configured.</div>
|
||||
{% endif %}
|
||||
<div><strong>Excludes:</strong> {{ effective_config.excludes|length }}</div>
|
||||
{% if effective_config.excludes %}
|
||||
<pre>{{ effective_config.excludes|join:" " }}</pre>
|
||||
{% else %}
|
||||
<div class="muted">No exclude rules configured.</div>
|
||||
<span class="status failed">disabled</span>
|
||||
{% endif %}
|
||||
<span class="muted">{{ host.address }}</span>
|
||||
</div>
|
||||
{% if active_run %}
|
||||
<a class="status-summary {{ active_run.status }}" href="{% url 'run_detail' active_run.id %}">
|
||||
<span class="status {{ active_run.status }}">{{ active_run.status }}</span>
|
||||
<strong>Run {{ active_run.id }} is active.</strong>
|
||||
</a>
|
||||
{% elif counts.failed_runs %}
|
||||
<a class="status-summary failed" href="{% url 'runs_list' %}?host={{ host.host }}&status=failed&review=needed">
|
||||
<span class="status failed">failed</span>
|
||||
<strong>{{ counts.failed_runs }} failed run{{ counts.failed_runs|pluralize }} need review.</strong>
|
||||
</a>
|
||||
{% elif retention_warning.has_warning %}
|
||||
<span class="status-summary warning">
|
||||
<span class="status warning">warning</span>
|
||||
<strong>Retention needs attention.</strong>
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="status-summary success">
|
||||
<span class="status ok">ok</span>
|
||||
<strong>No active blockers for this host.</strong>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
<div class="host-control-meta">
|
||||
<div><span class="label">Snapshots</span><strong>{{ counts.snapshots }}</strong></div>
|
||||
<div><span class="label">Runs</span><strong>{{ counts.runs }}</strong></div>
|
||||
<div><span class="label">Incomplete</span><strong>{{ counts.incomplete_snapshots }}</strong></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Snapshot Discovery</h2>
|
||||
<div class="stack">
|
||||
<div><strong>Backup root:</strong> {{ discovery.backup_root|default:"" }}</div>
|
||||
<div><strong>Host root:</strong> {{ discovery.host_root|default:"" }}</div>
|
||||
<div><strong>Status:</strong> {{ discovery.message }}</div>
|
||||
{% if discovery.kind_counts %}
|
||||
<div><strong>On disk:</strong>
|
||||
scheduled {{ discovery.kind_counts.scheduled|default:0 }},
|
||||
manual {{ discovery.kind_counts.manual|default:0 }},
|
||||
incomplete {{ discovery.kind_counts.incomplete|default:0 }}
|
||||
<article class="panel host-control-panel">
|
||||
<h2>Backup Control</h2>
|
||||
<div class="operator-state">
|
||||
{% if active_run %}
|
||||
<span class="status {{ active_run.status }}">{{ active_run.status }}</span>
|
||||
<a href="{% url 'run_detail' active_run.id %}">Run {{ active_run.id }}</a>
|
||||
{% elif has_global_config and host.enabled %}
|
||||
<span class="status {{ backup_gate.state }}">{{ backup_gate.state }}</span>
|
||||
<span class="muted">{{ backup_gate.message }}</span>
|
||||
{% elif not host.enabled %}
|
||||
<span class="status failed">disabled</span>
|
||||
{% elif not has_global_config %}
|
||||
<span class="status failed">missing global config</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<section class="actions inline" aria-label="Quick backup actions">
|
||||
<form class="inline-form" method="post" action="{% url 'queue_manual_backup' host.host %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="dry_run" value="on">
|
||||
<input type="hidden" name="verbose_output" value="on">
|
||||
<input type="hidden" name="prune_max_delete" value="10">
|
||||
<button type="submit" class="secondary" {% if not can_queue_dry_run %}disabled{% endif %}>Queue dry-run</button>
|
||||
</form>
|
||||
<form class="inline-form" method="post" action="{% url 'queue_manual_backup' host.host %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="prune_max_delete" value="10">
|
||||
<button type="submit" {% if not can_queue_real_backup %}disabled{% endif %}>Queue backup</button>
|
||||
</form>
|
||||
</section>
|
||||
{% if active_run %}
|
||||
<p class="muted">Wait for the active run to finish, or cancel it from the run detail page.</p>
|
||||
{% elif not can_queue_dry_run or not can_queue_real_backup %}
|
||||
{% if not has_global_config %}
|
||||
<p class="muted">Create the default global config before queueing backups.</p>
|
||||
{% elif not host.enabled %}
|
||||
<p class="muted">Enable this host before queueing backups.</p>
|
||||
{% elif backup_gate.real_blockers %}
|
||||
<p class="muted">Real backups are blocked by failed preflight checks. Dry-runs may still be available when storage-only checks fail.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
<article class="panel host-control-panel">
|
||||
<h2>Schedule <a class="button-link secondary compact" href="{% url 'edit_host_schedule' host.host %}">Edit schedule</a></h2>
|
||||
{% if schedule %}
|
||||
<div class="host-control-meta">
|
||||
<div><span class="label">Schedule expression</span><strong>{{ schedule.cron_expr }}</strong></div>
|
||||
<div><span class="label">Next run</span><strong>{% if next_run_at %}{{ next_run_at|date:"Y-m-d H:i T" }}{% else %}none{% endif %}</strong></div>
|
||||
<div><span class="label">Timezone</span><strong>{{ scheduler_timezone }}</strong></div>
|
||||
<div><span class="label">Prune</span><strong>{{ schedule.prune|yesno:"yes,no" }}</strong></div>
|
||||
<div><span class="label">Last status</span><strong>{{ schedule.last_status|default:"none" }}</strong></div>
|
||||
</div>
|
||||
<p class="muted">Evaluated by the pobsync scheduler service.</p>
|
||||
{% else %}
|
||||
<p class="muted">No schedule configured.</p>
|
||||
<a class="button-link secondary compact" href="{% url 'edit_host_schedule' host.host %}">Add schedule</a>
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
<article class="panel host-control-panel">
|
||||
<h2>Current Activity</h2>
|
||||
{% if latest_runs %}
|
||||
{% with run=latest_runs.0 %}
|
||||
<a class="activity-row" href="{% url 'run_detail' run.id %}">
|
||||
<span class="status {{ run.status }}">{{ run.status }}</span>
|
||||
<span>
|
||||
<strong>Run {{ run.id }}</strong>
|
||||
<span class="muted">{{ run.run_type }} · {{ run.started_at|default:run.created_at }}</span>
|
||||
</span>
|
||||
</a>
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<p class="muted">No backup runs recorded for this host.</p>
|
||||
{% endif %}
|
||||
{% if stats_summary.latest_run.duration_seconds is not None %}
|
||||
<div class="host-control-meta">
|
||||
<div><span class="label">Latest duration</span><strong>{{ stats_summary.latest_run.duration_seconds }}s</strong></div>
|
||||
<div><span class="label">New data</span><strong>{{ stats_summary.latest_run.rsync.literal_data_bytes|filesizeformat }}</strong></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="grid" aria-label="Host summary">
|
||||
<div class="metric"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></div>
|
||||
<div class="metric"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></div>
|
||||
<div class="metric"><div class="label">Queued</div><div class="value">{{ counts.queued_runs }}</div></div>
|
||||
<div class="metric"><div class="label">Running</div><div class="value">{{ counts.running_runs }}</div></div>
|
||||
<div class="metric"><div class="label">Failed Runs</div><div class="value">{{ counts.failed_runs }}</div></div>
|
||||
<div class="metric"><div class="label">Incomplete</div><div class="value">{{ counts.incomplete_snapshots }}</div></div>
|
||||
</section>
|
||||
|
||||
{% if stats_summary.runs %}
|
||||
@@ -214,104 +229,197 @@
|
||||
<div class="metric"><div class="label">Failed</div><div class="value">{{ host_check_summary.failed }}</div></div>
|
||||
<div class="metric"><div class="label">Skipped</div><div class="value">{{ host_check_summary.skipped }}</div></div>
|
||||
</section>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Check</th>
|
||||
<th>Message</th>
|
||||
<th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for check in host_checks %}
|
||||
<tr>
|
||||
<td><span class="status {{ check.status }}">{{ check.status }}</span></td>
|
||||
<td>{{ check.name }}</td>
|
||||
<td>{{ check.message }}</td>
|
||||
<td class="muted">{{ check.detail }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="record-list">
|
||||
{% for check in host_checks %}
|
||||
<article class="record-card">
|
||||
<div class="record-card-header">
|
||||
<div class="record-title">
|
||||
<strong>{{ check.name }}</strong>
|
||||
<span class="muted">{{ check.message }}</span>
|
||||
</div>
|
||||
<span class="status {{ check.status }}">{{ check.status }}</span>
|
||||
</div>
|
||||
{% if check.detail %}
|
||||
<div class="record-fact">
|
||||
<span class="label">Detail</span>
|
||||
<span class="muted">{{ check.detail }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if last_preflight %}
|
||||
<div class="panel-grid">
|
||||
<section class="panel">
|
||||
<h2>Connection Preflight</h2>
|
||||
<div class="stack spaced">
|
||||
<div><strong>Status:</strong> <span class="status {% if last_preflight.ok %}ok{% else %}failed{% endif %}">{% if last_preflight.ok %}ok{% else %}failed{% endif %}</span></div>
|
||||
<div><strong>Target:</strong> {{ last_preflight.target }}</div>
|
||||
<div><strong>Backup source:</strong> {{ last_preflight.source_root }}</div>
|
||||
<div><strong>Remote rsync:</strong> {{ last_preflight.rsync_binary }}</div>
|
||||
<h2>Configuration</h2>
|
||||
<div class="host-control-meta">
|
||||
<div><span class="label">Address</span><strong>{{ host.address }}</strong></div>
|
||||
<div><span class="label">SSH key</span><strong>{{ host.ssh_credential|default:"global default" }}</strong></div>
|
||||
<div><span class="label">SSH</span><strong>{{ host.ssh_user|default:"global" }}{% if host.ssh_port %}:{{ host.ssh_port }}{% endif %}</strong></div>
|
||||
<div><span class="label">Backup source</span><strong>{{ host.source_root|default:"global default" }}</strong></div>
|
||||
<div><span class="label">Retention</span><strong>d{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}</strong></div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Check</th>
|
||||
<th>Message</th>
|
||||
<th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<div class="actions inline">
|
||||
<a class="button-link secondary compact" href="{% url 'edit_host_config' host.host %}">Edit config</a>
|
||||
<a class="button-link secondary compact" href="{% url 'host_retention_plan' host.host %}">Plan retention</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Connection Preflight & SSH</h2>
|
||||
{% if last_preflight %}
|
||||
<div class="host-control-meta">
|
||||
<div>
|
||||
<span class="label">Preflight</span>
|
||||
<strong>
|
||||
<span class="status {% if last_preflight.ok %}ok{% else %}failed{% endif %}">
|
||||
{% if last_preflight.ok %}ok{% else %}failed{% endif %}
|
||||
</span>
|
||||
</strong>
|
||||
</div>
|
||||
<div><span class="label">Target</span><strong>{{ last_preflight.target }}</strong></div>
|
||||
<div><span class="label">Backup source</span><strong>{{ last_preflight.source_root }}</strong></div>
|
||||
<div><span class="label">Remote rsync</span><strong>{{ last_preflight.rsync_binary }}</strong></div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">No connection preflight recorded yet.</p>
|
||||
{% endif %}
|
||||
<div class="actions inline">
|
||||
<form method="post" action="{% url 'run_host_preflight' host.host %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="secondary compact">Run connection preflight</button>
|
||||
</form>
|
||||
<form method="post" action="{% url 'scan_host_known_key' host.host %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="secondary compact">Scan SSH host key</button>
|
||||
</form>
|
||||
</div>
|
||||
{% if last_preflight.checks %}
|
||||
<div class="activity-list">
|
||||
{% for check in last_preflight.checks %}
|
||||
<tr>
|
||||
<td><span class="status {% if check.ok %}ok{% else %}failed{% endif %}">{% if check.ok %}ok{% else %}failed{% endif %}</span></td>
|
||||
<td>{{ check.name }}</td>
|
||||
<td>{{ check.message }}</td>
|
||||
<td class="muted">{{ check.detail }}</td>
|
||||
</tr>
|
||||
<div class="activity-row">
|
||||
<span class="status {% if check.ok %}ok{% else %}failed{% endif %}">
|
||||
{% if check.ok %}ok{% else %}failed{% endif %}
|
||||
</span>
|
||||
<span>
|
||||
<strong>{{ check.name }}</strong>
|
||||
<span class="muted">{{ check.message }}{% if check.detail %} · {{ check.detail }}{% endif %}</span>
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Snapshot Storage</h2>
|
||||
<div class="host-control-meta">
|
||||
<div><span class="label">Backup root</span><strong>{{ discovery.backup_root|default:"" }}</strong></div>
|
||||
<div><span class="label">Host root</span><strong>{{ discovery.host_root|default:"" }}</strong></div>
|
||||
<div><span class="label">Status</span><strong>{{ discovery.message }}</strong></div>
|
||||
{% if discovery.kind_counts %}
|
||||
<div>
|
||||
<span class="label">On disk</span>
|
||||
<strong>
|
||||
scheduled {{ discovery.kind_counts.scheduled|default:0 }},
|
||||
manual {{ discovery.kind_counts.manual|default:0 }},
|
||||
incomplete {{ discovery.kind_counts.incomplete|default:0 }}
|
||||
</strong>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="actions inline">
|
||||
<form method="post" action="{% url 'discover_host_snapshots' host.host %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="secondary compact">Discover snapshots</button>
|
||||
</form>
|
||||
<form method="post" action="{% url 'prepare_host_directories' host.host %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="secondary compact">Prepare directories</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% if effective_config %}
|
||||
<section class="panel">
|
||||
<h2>Effective Config</h2>
|
||||
<p class="muted">Runtime settings after global defaults and host overrides are combined.</p>
|
||||
<div class="record-list">
|
||||
<article class="record-card">
|
||||
<div class="record-card-header">
|
||||
<div class="record-title">
|
||||
<strong>Backup target</strong>
|
||||
<span class="muted">Source and destination used by rsync.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="record-facts">
|
||||
<div class="record-fact"><span class="label">Backup source:</span><strong>{{ effective_config.source_root }}</strong></div>
|
||||
<div class="record-fact"><span class="label">Destination subdir:</span><strong>{{ effective_config.destination_subdir|default:"none" }}</strong></div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="record-card">
|
||||
<div class="record-card-header">
|
||||
<div class="record-title">
|
||||
<strong>Connection</strong>
|
||||
<span class="muted">SSH and rsync execution settings.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="record-facts">
|
||||
<div class="record-fact"><span class="label">SSH:</span><strong>{{ effective_config.ssh.user }}@{{ host.address }}:{{ effective_config.ssh.port }}</strong></div>
|
||||
<div class="record-fact"><span class="label">SSH key:</span><strong>{{ effective_config.ssh.credential|default:"none selected" }}</strong></div>
|
||||
<div class="record-fact"><span class="label">SSH options:</span><span>{{ effective_config.ssh.options|join:" " }}</span></div>
|
||||
<div class="record-fact"><span class="label">Rsync binary:</span><strong>{{ effective_config.rsync.binary }}</strong></div>
|
||||
<div class="record-fact"><span class="label">Rsync args:</span><span>{{ effective_config.rsync.args|join:" " }}</span></div>
|
||||
<div class="record-fact"><span class="label">Timeout:</span><strong>{{ effective_config.rsync.timeout_seconds }}s</strong></div>
|
||||
<div class="record-fact"><span class="label">Bandwidth limit:</span><strong>{{ effective_config.rsync.bwlimit_kbps }} KB/s</strong></div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="record-card">
|
||||
<div class="record-card-header">
|
||||
<div class="record-title">
|
||||
<strong>Selection & retention</strong>
|
||||
<span class="muted">Include/exclude rules and retention counts.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="record-facts">
|
||||
<div class="record-fact">
|
||||
<span class="label">Retention:</span>
|
||||
<strong>
|
||||
d{{ effective_config.retention.daily }}
|
||||
w{{ effective_config.retention.weekly }}
|
||||
m{{ effective_config.retention.monthly }}
|
||||
y{{ effective_config.retention.yearly }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="record-fact"><span class="label">Includes:</span><strong>{{ effective_config.includes|length }}</strong></div>
|
||||
<div class="record-fact"><span class="label">Excludes:</span><strong>{{ effective_config.excludes|length }}</strong></div>
|
||||
</div>
|
||||
<div class="two-col">
|
||||
<div class="stack">
|
||||
{% if effective_config.includes %}
|
||||
<pre>{{ effective_config.includes|join:" " }}</pre>
|
||||
{% else %}
|
||||
<div class="muted">No include rules configured.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="stack">
|
||||
{% if effective_config.excludes %}
|
||||
<pre>{{ effective_config.excludes|join:" " }}</pre>
|
||||
{% else %}
|
||||
<div class="muted">No exclude rules configured.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="panel">
|
||||
<h2>Backup Control</h2>
|
||||
<div class="operator-state">
|
||||
{% if active_run %}
|
||||
<span class="status {{ active_run.status }}">{{ active_run.status }}</span>
|
||||
<a href="{% url 'run_detail' active_run.id %}">Run {{ active_run.id }}</a>
|
||||
{% elif has_global_config and host.enabled %}
|
||||
<span class="status {{ backup_gate.state }}">{{ backup_gate.state }}</span>
|
||||
<span class="muted">{{ backup_gate.message }}</span>
|
||||
{% elif not host.enabled %}
|
||||
<span class="status failed">disabled</span>
|
||||
{% elif not has_global_config %}
|
||||
<span class="status failed">missing global config</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<section class="actions inline" aria-label="Quick backup actions">
|
||||
<form class="inline-form" method="post" action="{% url 'queue_manual_backup' host.host %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="dry_run" value="on">
|
||||
<input type="hidden" name="verbose_output" value="on">
|
||||
<input type="hidden" name="prune_max_delete" value="10">
|
||||
<button type="submit" class="secondary" {% if not can_queue_dry_run %}disabled{% endif %}>Queue dry-run</button>
|
||||
</form>
|
||||
<form class="inline-form" method="post" action="{% url 'queue_manual_backup' host.host %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="prune_max_delete" value="10">
|
||||
<button type="submit" {% if not can_queue_real_backup %}disabled{% endif %}>Queue backup</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if active_run %}
|
||||
<p class="muted">Wait for the active run to finish, or cancel it from the run detail page.</p>
|
||||
{% elif not can_queue_dry_run or not can_queue_real_backup %}
|
||||
{% if not has_global_config %}
|
||||
<p class="muted">Create the default global config before queueing backups.</p>
|
||||
{% elif not host.enabled %}
|
||||
<p class="muted">Enable this host before queueing backups.</p>
|
||||
{% elif backup_gate.real_blockers %}
|
||||
<p class="muted">Real backups are blocked by failed preflight checks. Dry-runs may still be available when storage-only checks fail.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<h3>Advanced Options</h3>
|
||||
<h2>Backup Options</h2>
|
||||
<p class="muted">Use this when the quick actions above need a custom label, include/exclude override, or prune limit.</p>
|
||||
<form method="post" action="{% url 'queue_manual_backup' host.host %}" class="form-grid">
|
||||
{% csrf_token %}
|
||||
{{ manual_backup_form.non_field_errors }}
|
||||
@@ -332,58 +440,88 @@
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Latest Runs</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Started</th>
|
||||
<th>Ended</th>
|
||||
<th>Snapshot</th>
|
||||
<th>Base</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for run in latest_runs %}
|
||||
<tr>
|
||||
<td><a href="{% url 'run_detail' run.id %}"><span class="status {{ run.status }}">{{ run.status }}</span></a></td>
|
||||
<td>{{ run.started_at|default:"" }}</td>
|
||||
<td>{{ run.ended_at|default:"" }}</td>
|
||||
<td>{% if run.snapshot %}<a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a>{% else %}<span class="muted">{{ run.snapshot_path }}</span>{% endif %}</td>
|
||||
<td>{{ run.base_path|default:"" }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5" class="muted">No backup runs recorded for this host.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<h2>Latest Runs <a class="button-link secondary compact" href="{% url 'runs_list' %}?host={{ host.host }}">View all</a></h2>
|
||||
<div class="record-list">
|
||||
{% for run in latest_runs %}
|
||||
<article class="record-card">
|
||||
<div class="record-card-header">
|
||||
<div class="record-title">
|
||||
<a href="{% url 'run_detail' run.id %}">Run {{ run.id }}</a>
|
||||
<span class="muted">{{ run.run_type }}{% if run.result.duration_seconds %} · {{ run.result.duration_seconds }}s{% endif %}</span>
|
||||
</div>
|
||||
<span class="status {{ run.status }}">{{ run.status }}</span>
|
||||
</div>
|
||||
<div class="record-facts">
|
||||
<div class="record-fact">
|
||||
<span class="label">Started</span>
|
||||
<strong>{{ run.started_at|default:run.created_at }}</strong>
|
||||
</div>
|
||||
<div class="record-fact">
|
||||
<span class="label">Ended</span>
|
||||
<strong>{{ run.ended_at|default:"running or queued" }}</strong>
|
||||
</div>
|
||||
<div class="record-fact">
|
||||
<span class="label">Snapshot</span>
|
||||
{% if run.snapshot %}
|
||||
<strong><a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a></strong>
|
||||
{% elif run.snapshot_path %}
|
||||
<span class="muted">{{ run.snapshot_path }}</span>
|
||||
{% else %}
|
||||
<span class="muted">none</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="record-fact">
|
||||
<span class="label">Base</span>
|
||||
<span class="muted">{{ run.base_path|default:"none" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% empty %}
|
||||
<p class="muted">No backup runs recorded for this host.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Snapshots</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kind</th>
|
||||
<th>Status</th>
|
||||
<th>Started</th>
|
||||
<th>Dirname</th>
|
||||
<th>Base</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for snapshot in snapshots %}
|
||||
<tr>
|
||||
<td>{{ snapshot.kind }}</td>
|
||||
<td>{{ snapshot.status }}</td>
|
||||
<td>{{ snapshot.started_at|default:"" }}</td>
|
||||
<td><a href="{% url 'snapshot_detail' snapshot.id %}">{{ snapshot.dirname }}</a></td>
|
||||
<td>{% if snapshot.base %}<a href="{% url 'snapshot_detail' snapshot.base.id %}">{{ snapshot.base.dirname }}</a>{% else %}{{ snapshot.base_dirname }}{% endif %}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5" class="muted">No snapshots discovered for this host.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<h2>Snapshots <a class="button-link secondary compact" href="{% url 'snapshots_list' %}?host={{ host.host }}">View all</a></h2>
|
||||
<div class="record-list">
|
||||
{% for snapshot in snapshots %}
|
||||
<article class="record-card">
|
||||
<div class="record-card-header">
|
||||
<div class="record-title">
|
||||
<a href="{% url 'snapshot_detail' snapshot.id %}">{{ snapshot.dirname }}</a>
|
||||
<span class="muted">{{ snapshot.kind }}</span>
|
||||
</div>
|
||||
<span class="status {{ snapshot.status }}">{{ snapshot.status }}</span>
|
||||
</div>
|
||||
<div class="record-facts">
|
||||
<div class="record-fact">
|
||||
<span class="label">Started</span>
|
||||
<strong>{{ snapshot.started_at|default:"unknown" }}</strong>
|
||||
</div>
|
||||
<div class="record-fact">
|
||||
<span class="label">Ended</span>
|
||||
<strong>{{ snapshot.ended_at|default:"unknown" }}</strong>
|
||||
</div>
|
||||
<div class="record-fact">
|
||||
<span class="label">Base</span>
|
||||
{% if snapshot.base %}
|
||||
<strong><a href="{% url 'snapshot_detail' snapshot.base.id %}">{{ snapshot.base.dirname }}</a></strong>
|
||||
{% elif snapshot.base_dirname %}
|
||||
<span class="muted">{{ snapshot.base_dirname }}</span>
|
||||
{% else %}
|
||||
<span class="muted">none</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="record-fact">
|
||||
<span class="label">Path</span>
|
||||
<span class="muted">{{ snapshot.path|default:"not recorded" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% empty %}
|
||||
<p class="muted">No snapshots discovered for this host.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -923,7 +923,7 @@ class ViewTests(TestCase):
|
||||
self.assertContains(response, "15 2 * * *")
|
||||
self.assertContains(response, "Schedule expression")
|
||||
self.assertContains(response, "Evaluated by the pobsync scheduler service.")
|
||||
self.assertContains(response, "Next run:")
|
||||
self.assertContains(response, "Next run")
|
||||
self.assertContains(response, "UTC")
|
||||
self.assertContains(response, "20260519-021500Z__ABCDEFGH")
|
||||
self.assertContains(response, "Discover snapshots")
|
||||
@@ -936,10 +936,12 @@ class ViewTests(TestCase):
|
||||
self.assertContains(response, "Host Check")
|
||||
self.assertContains(response, reverse("prepare_host_directories", args=[host.host]))
|
||||
self.assertContains(response, "warning")
|
||||
self.assertContains(response, "Snapshot Discovery")
|
||||
self.assertContains(response, "Snapshot Storage")
|
||||
self.assertContains(response, reverse("queue_manual_backup", args=[host.host]))
|
||||
self.assertContains(response, reverse("run_detail", args=[BackupRun.objects.get().id]))
|
||||
self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id]))
|
||||
self.assertContains(response, f'{reverse("runs_list")}?host={host.host}', html=False)
|
||||
self.assertContains(response, f'{reverse("snapshots_list")}?host={host.host}', html=False)
|
||||
|
||||
def test_host_detail_renders_effective_config_preview(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
@@ -1221,7 +1223,8 @@ class ViewTests(TestCase):
|
||||
response = self.client.get(reverse("host_detail", args=[host.host]))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, f"Host root:</strong> {backup_root / host.host}")
|
||||
self.assertContains(response, "Host root")
|
||||
self.assertContains(response, str(backup_root / host.host))
|
||||
self.assertContains(response, "Found 2 snapshot directories")
|
||||
self.assertContains(response, "scheduled 1")
|
||||
self.assertContains(response, "incomplete 1")
|
||||
|
||||
Reference in New Issue
Block a user