5 Commits

Author SHA1 Message Date
c0eca3da55 Merge pull request 'issue-26-host-detail-control-page' (#34) from issue-26-host-detail-control-page into master
Reviewed-on: #34
2026-05-21 14:05:51 +02:00
212813e066 (ui) Make host diagnostics easier to scan
Replace the raw host check table with diagnostic cards and group effective
runtime config into operator-focused sections for backup target, connection,
and selection/retention details.

Refs #26
2026-05-21 14:03:43 +02:00
ab5291b8d3 (ui) Show host runs and snapshots as record cards
Replace the database-style Latest Runs and Snapshots tables on the host
detail page with scannable record cards and host-filtered View all links.

Refs #26
2026-05-21 13:59:16 +02:00
1929196287 (ui) Group host detail actions by context
Move host-level actions out of the page header and into the panels where
operators expect them: configuration, connection preflight, and snapshot
storage. This keeps the host control page calmer while preserving the same
actions.

Refs #26
2026-05-21 13:53:10 +02:00
9e75273fc5 (ui) Promote host detail operator controls
Add a first-screen host control workspace with status, backup actions,
schedule state, and current activity so the host detail page behaves as the
primary operator page instead of starting with raw configuration blocks.

Refs #26
2026-05-21 13:40:37 +02:00
3 changed files with 485 additions and 251 deletions

View File

@@ -241,6 +241,13 @@
.stack { display: grid; gap: 5px; } .stack { display: grid; gap: 5px; }
.stack.spaced { margin-bottom: 14px; } .stack.spaced { margin-bottom: 14px; }
.two-col { display: grid; gap: 18px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } .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 { .actions {
align-items: center; align-items: center;
display: flex; display: flex;
@@ -303,6 +310,53 @@
display: grid; display: grid;
gap: 8px; 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, .action-row,
.activity-row, .activity-row,
.schedule-row { .schedule-row {
@@ -370,6 +424,43 @@
gap: 10px; gap: 10px;
justify-content: space-between; justify-content: space-between;
padding-top: 8px; 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 { .status-summary {
align-items: center; align-items: center;
@@ -639,8 +730,10 @@
display: grid; display: grid;
} }
.page-header .actions { justify-content: flex-start; } .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; } .dashboard-priority-grid { grid-template-columns: 1fr; }
.host-control-grid { grid-template-columns: 1fr; }
.schedule-row { grid-template-columns: 1fr; } .schedule-row { grid-template-columns: 1fr; }
.schedule-time { justify-items: start; text-align: left; } .schedule-time { justify-items: start; text-align: left; }
.host-card-header { display: grid; } .host-card-header { display: grid; }

View File

@@ -9,70 +9,8 @@
<h1>{{ host.host }}</h1> <h1>{{ host.host }}</h1>
<div class="page-subtitle">{{ host.address }} · {{ host.enabled|yesno:"enabled,disabled" }}</div> <div class="page-subtitle">{{ host.address }} · {{ host.enabled|yesno:"enabled,disabled" }}</div>
</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> </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 %} {% if retention_warning.has_warning %}
<section class="panel highlight warning"> <section class="panel highlight warning">
<h2>Retention Warnings</h2> <h2>Retention Warnings</h2>
@@ -101,60 +39,137 @@
</section> </section>
{% endif %} {% endif %}
{% if effective_config %} <section class="host-control-grid" aria-label="Host control workspace">
<section class="panel"> <article class="panel host-control-panel">
<h2>Effective Config</h2> <h2>Host Status</h2>
<div class="two-col"> <div class="host-control-primary">
<div class="stack"> <div>
<div><strong>Backup source:</strong> {{ effective_config.source_root }}</div> {% if host.enabled %}
<div><strong>Destination subdir:</strong> {{ effective_config.destination_subdir|default:"none" }}</div> <span class="status ok">enabled</span>
<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:"&#10;" }}</pre>
{% else %} {% else %}
<div class="muted">No include rules configured.</div> <span class="status failed">disabled</span>
{% endif %}
<div><strong>Excludes:</strong> {{ effective_config.excludes|length }}</div>
{% if effective_config.excludes %}
<pre>{{ effective_config.excludes|join:"&#10;" }}</pre>
{% else %}
<div class="muted">No exclude rules configured.</div>
{% endif %} {% endif %}
<span class="muted">{{ host.address }}</span>
</div> </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 }}&amp;status=failed&amp;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> </div>
</section> <div class="host-control-meta">
{% endif %} <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"> <article class="panel host-control-panel">
<h2>Snapshot Discovery</h2> <h2>Backup Control</h2>
<div class="stack"> <div class="operator-state">
<div><strong>Backup root:</strong> {{ discovery.backup_root|default:"" }}</div> {% if active_run %}
<div><strong>Host root:</strong> {{ discovery.host_root|default:"" }}</div> <span class="status {{ active_run.status }}">{{ active_run.status }}</span>
<div><strong>Status:</strong> {{ discovery.message }}</div> <a href="{% url 'run_detail' active_run.id %}">Run {{ active_run.id }}</a>
{% if discovery.kind_counts %} {% elif has_global_config and host.enabled %}
<div><strong>On disk:</strong> <span class="status {{ backup_gate.state }}">{{ backup_gate.state }}</span>
scheduled {{ discovery.kind_counts.scheduled|default:0 }}, <span class="muted">{{ backup_gate.message }}</span>
manual {{ discovery.kind_counts.manual|default:0 }}, {% elif not host.enabled %}
incomplete {{ discovery.kind_counts.incomplete|default:0 }} <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> </div>
{% endif %} {% 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> </section>
{% if stats_summary.runs %} {% 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">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> <div class="metric"><div class="label">Skipped</div><div class="value">{{ host_check_summary.skipped }}</div></div>
</section> </section>
<table> <div class="record-list">
<thead> {% for check in host_checks %}
<tr> <article class="record-card">
<th>Status</th> <div class="record-card-header">
<th>Check</th> <div class="record-title">
<th>Message</th> <strong>{{ check.name }}</strong>
<th>Detail</th> <span class="muted">{{ check.message }}</span>
</tr> </div>
</thead> <span class="status {{ check.status }}">{{ check.status }}</span>
<tbody> </div>
{% for check in host_checks %} {% if check.detail %}
<tr> <div class="record-fact">
<td><span class="status {{ check.status }}">{{ check.status }}</span></td> <span class="label">Detail</span>
<td>{{ check.name }}</td> <span class="muted">{{ check.detail }}</span>
<td>{{ check.message }}</td> </div>
<td class="muted">{{ check.detail }}</td> {% endif %}
</tr> </article>
{% endfor %} {% endfor %}
</tbody> </div>
</table>
</section> </section>
{% if last_preflight %} <div class="panel-grid">
<section class="panel"> <section class="panel">
<h2>Connection Preflight</h2> <h2>Configuration</h2>
<div class="stack spaced"> <div class="host-control-meta">
<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><span class="label">Address</span><strong>{{ host.address }}</strong></div>
<div><strong>Target:</strong> {{ last_preflight.target }}</div> <div><span class="label">SSH key</span><strong>{{ host.ssh_credential|default:"global default" }}</strong></div>
<div><strong>Backup source:</strong> {{ last_preflight.source_root }}</div> <div><span class="label">SSH</span><strong>{{ host.ssh_user|default:"global" }}{% if host.ssh_port %}:{{ host.ssh_port }}{% endif %}</strong></div>
<div><strong>Remote rsync:</strong> {{ last_preflight.rsync_binary }}</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> </div>
<table> <div class="actions inline">
<thead> <a class="button-link secondary compact" href="{% url 'edit_host_config' host.host %}">Edit config</a>
<tr> <a class="button-link secondary compact" href="{% url 'host_retention_plan' host.host %}">Plan retention</a>
<th>Status</th> </div>
<th>Check</th> </section>
<th>Message</th>
<th>Detail</th> <section class="panel">
</tr> <h2>Connection Preflight &amp; SSH</h2>
</thead> {% if last_preflight %}
<tbody> <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 %} {% for check in last_preflight.checks %}
<tr> <div class="activity-row">
<td><span class="status {% if check.ok %}ok{% else %}failed{% endif %}">{% if check.ok %}ok{% else %}failed{% endif %}</span></td> <span class="status {% if check.ok %}ok{% else %}failed{% endif %}">
<td>{{ check.name }}</td> {% if check.ok %}ok{% else %}failed{% endif %}
<td>{{ check.message }}</td> </span>
<td class="muted">{{ check.detail }}</td> <span>
</tr> <strong>{{ check.name }}</strong>
<span class="muted">{{ check.message }}{% if check.detail %} · {{ check.detail }}{% endif %}</span>
</span>
</div>
{% endfor %} {% endfor %}
</tbody> </div>
</table> {% 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 &amp; 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:"&#10;" }}</pre>
{% else %}
<div class="muted">No include rules configured.</div>
{% endif %}
</div>
<div class="stack">
{% if effective_config.excludes %}
<pre>{{ effective_config.excludes|join:"&#10;" }}</pre>
{% else %}
<div class="muted">No exclude rules configured.</div>
{% endif %}
</div>
</div>
</article>
</div>
</section> </section>
{% endif %} {% endif %}
<section class="panel"> <section class="panel">
<h2>Backup Control</h2> <h2>Backup Options</h2>
<div class="operator-state"> <p class="muted">Use this when the quick actions above need a custom label, include/exclude override, or prune limit.</p>
{% 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>
<form method="post" action="{% url 'queue_manual_backup' host.host %}" class="form-grid"> <form method="post" action="{% url 'queue_manual_backup' host.host %}" class="form-grid">
{% csrf_token %} {% csrf_token %}
{{ manual_backup_form.non_field_errors }} {{ manual_backup_form.non_field_errors }}
@@ -332,58 +440,88 @@
</section> </section>
<section class="panel"> <section class="panel">
<h2>Latest Runs</h2> <h2>Latest Runs <a class="button-link secondary compact" href="{% url 'runs_list' %}?host={{ host.host }}">View all</a></h2>
<table> <div class="record-list">
<thead> {% for run in latest_runs %}
<tr> <article class="record-card">
<th>Status</th> <div class="record-card-header">
<th>Started</th> <div class="record-title">
<th>Ended</th> <a href="{% url 'run_detail' run.id %}">Run {{ run.id }}</a>
<th>Snapshot</th> <span class="muted">{{ run.run_type }}{% if run.result.duration_seconds %} · {{ run.result.duration_seconds }}s{% endif %}</span>
<th>Base</th> </div>
</tr> <span class="status {{ run.status }}">{{ run.status }}</span>
</thead> </div>
<tbody> <div class="record-facts">
{% for run in latest_runs %} <div class="record-fact">
<tr> <span class="label">Started</span>
<td><a href="{% url 'run_detail' run.id %}"><span class="status {{ run.status }}">{{ run.status }}</span></a></td> <strong>{{ run.started_at|default:run.created_at }}</strong>
<td>{{ run.started_at|default:"" }}</td> </div>
<td>{{ run.ended_at|default:"" }}</td> <div class="record-fact">
<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> <span class="label">Ended</span>
<td>{{ run.base_path|default:"" }}</td> <strong>{{ run.ended_at|default:"running or queued" }}</strong>
</tr> </div>
{% empty %} <div class="record-fact">
<tr><td colspan="5" class="muted">No backup runs recorded for this host.</td></tr> <span class="label">Snapshot</span>
{% endfor %} {% if run.snapshot %}
</tbody> <strong><a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a></strong>
</table> {% 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>
<section class="panel"> <section class="panel">
<h2>Snapshots</h2> <h2>Snapshots <a class="button-link secondary compact" href="{% url 'snapshots_list' %}?host={{ host.host }}">View all</a></h2>
<table> <div class="record-list">
<thead> {% for snapshot in snapshots %}
<tr> <article class="record-card">
<th>Kind</th> <div class="record-card-header">
<th>Status</th> <div class="record-title">
<th>Started</th> <a href="{% url 'snapshot_detail' snapshot.id %}">{{ snapshot.dirname }}</a>
<th>Dirname</th> <span class="muted">{{ snapshot.kind }}</span>
<th>Base</th> </div>
</tr> <span class="status {{ snapshot.status }}">{{ snapshot.status }}</span>
</thead> </div>
<tbody> <div class="record-facts">
{% for snapshot in snapshots %} <div class="record-fact">
<tr> <span class="label">Started</span>
<td>{{ snapshot.kind }}</td> <strong>{{ snapshot.started_at|default:"unknown" }}</strong>
<td>{{ snapshot.status }}</td> </div>
<td>{{ snapshot.started_at|default:"" }}</td> <div class="record-fact">
<td><a href="{% url 'snapshot_detail' snapshot.id %}">{{ snapshot.dirname }}</a></td> <span class="label">Ended</span>
<td>{% if snapshot.base %}<a href="{% url 'snapshot_detail' snapshot.base.id %}">{{ snapshot.base.dirname }}</a>{% else %}{{ snapshot.base_dirname }}{% endif %}</td> <strong>{{ snapshot.ended_at|default:"unknown" }}</strong>
</tr> </div>
{% empty %} <div class="record-fact">
<tr><td colspan="5" class="muted">No snapshots discovered for this host.</td></tr> <span class="label">Base</span>
{% endfor %} {% if snapshot.base %}
</tbody> <strong><a href="{% url 'snapshot_detail' snapshot.base.id %}">{{ snapshot.base.dirname }}</a></strong>
</table> {% 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> </section>
{% endblock %} {% endblock %}

View File

@@ -923,7 +923,7 @@ class ViewTests(TestCase):
self.assertContains(response, "15 2 * * *") self.assertContains(response, "15 2 * * *")
self.assertContains(response, "Schedule expression") self.assertContains(response, "Schedule expression")
self.assertContains(response, "Evaluated by the pobsync scheduler service.") 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, "UTC")
self.assertContains(response, "20260519-021500Z__ABCDEFGH") self.assertContains(response, "20260519-021500Z__ABCDEFGH")
self.assertContains(response, "Discover snapshots") self.assertContains(response, "Discover snapshots")
@@ -936,10 +936,12 @@ class ViewTests(TestCase):
self.assertContains(response, "Host Check") self.assertContains(response, "Host Check")
self.assertContains(response, reverse("prepare_host_directories", args=[host.host])) self.assertContains(response, reverse("prepare_host_directories", args=[host.host]))
self.assertContains(response, "warning") 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("queue_manual_backup", args=[host.host]))
self.assertContains(response, reverse("run_detail", args=[BackupRun.objects.get().id])) self.assertContains(response, reverse("run_detail", args=[BackupRun.objects.get().id]))
self.assertContains(response, reverse("snapshot_detail", args=[snapshot.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: def test_host_detail_renders_effective_config_preview(self) -> None:
self.client.force_login(self.staff_user) self.client.force_login(self.staff_user)
@@ -1221,7 +1223,8 @@ class ViewTests(TestCase):
response = self.client.get(reverse("host_detail", args=[host.host])) response = self.client.get(reverse("host_detail", args=[host.host]))
self.assertEqual(response.status_code, 200) 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, "Found 2 snapshot directories")
self.assertContains(response, "scheduled 1") self.assertContains(response, "scheduled 1")
self.assertContains(response, "incomplete 1") self.assertContains(response, "incomplete 1")