Make dashboard cards link to operational lists #31
@@ -168,6 +168,21 @@
|
|||||||
.metric.warning { border-color: #e7cf8a; background: #fffaf0; }
|
.metric.warning { border-color: #e7cf8a; background: #fffaf0; }
|
||||||
.metric.running { border-color: #e7cf8a; background: #fffaf0; }
|
.metric.running { border-color: #e7cf8a; background: #fffaf0; }
|
||||||
.metric.queued { border-color: #b5cdea; background: #eef6ff; }
|
.metric.queued { border-color: #b5cdea; background: #eef6ff; }
|
||||||
|
.metric-link {
|
||||||
|
color: inherit;
|
||||||
|
display: block;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.metric-link:hover {
|
||||||
|
border-color: #9eb2c8;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.metric-link:focus-visible {
|
||||||
|
outline: 3px solid #93c5fd;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
.panel {
|
.panel {
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -281,7 +296,23 @@
|
|||||||
.status-summary.failed { border-color: #e8b4b4; background: #fff7f7; color: var(--failed); }
|
.status-summary.failed { border-color: #e8b4b4; background: #fff7f7; color: var(--failed); }
|
||||||
.status-summary.warning,
|
.status-summary.warning,
|
||||||
.status-summary.running { border-color: #e7cf8a; background: #fffaf0; color: var(--running); }
|
.status-summary.running { border-color: #e7cf8a; background: #fffaf0; color: var(--running); }
|
||||||
.status-summary.queued { border-color: #b5cdea; background: #eef6ff; color: var(--link); }
|
.status-summary.queued { border-color: #b5cdea; background: #eef6ff; color: var(--link); }
|
||||||
|
a.status-summary {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
a.status-summary:hover {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.status-summary .summary-action {
|
||||||
|
color: var(--muted-strong);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
.operator-state {
|
.operator-state {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -33,14 +33,14 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<section class="grid" aria-label="Summary">
|
<section class="grid" aria-label="Summary">
|
||||||
<div class="metric"><div class="label">Hosts</div><div class="value">{{ counts.enabled_hosts }}/{{ counts.hosts }}</div></div>
|
<a class="metric metric-link" href="#hosts"><div class="label">Hosts</div><div class="value">{{ counts.enabled_hosts }}/{{ counts.hosts }}</div></a>
|
||||||
<div class="metric"><div class="label">Schedules</div><div class="value">{{ counts.enabled_schedules }}/{{ counts.schedules }}</div></div>
|
<a class="metric metric-link" href="{% url 'schedules_list' %}"><div class="label">Schedules</div><div class="value">{{ counts.enabled_schedules }}/{{ counts.schedules }}</div></a>
|
||||||
<div class="metric"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></div>
|
<a class="metric metric-link" href="{% url 'snapshots_list' %}"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></a>
|
||||||
<div class="metric"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></div>
|
<a class="metric metric-link" href="{% url 'runs_list' %}"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></a>
|
||||||
<div class="metric {% if counts.queued_runs %}queued{% endif %}"><div class="label">Queued</div><div class="value">{{ counts.queued_runs }}</div></div>
|
<a class="metric metric-link {% if counts.queued_runs %}queued{% endif %}" href="{% url 'runs_list' %}?status=queued"><div class="label">Queued</div><div class="value">{{ counts.queued_runs }}</div></a>
|
||||||
<div class="metric {% if counts.running_runs %}running{% endif %}"><div class="label">Running</div><div class="value">{{ counts.running_runs }}</div></div>
|
<a class="metric metric-link {% if counts.running_runs %}running{% endif %}" href="{% url 'runs_list' %}?status=running"><div class="label">Running</div><div class="value">{{ counts.running_runs }}</div></a>
|
||||||
<div class="metric {% if counts.warning_runs %}warning{% endif %}"><div class="label">Warnings</div><div class="value">{{ counts.warning_runs }}</div></div>
|
<a class="metric metric-link {% if counts.warning_runs %}warning{% endif %}" href="{% url 'runs_list' %}?status=warning&review=needed"><div class="label">Warnings</div><div class="value">{{ counts.warning_runs }}</div></a>
|
||||||
<div class="metric {% if counts.failed_runs %}failed{% endif %}"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></div>
|
<a class="metric metric-link {% if counts.failed_runs %}failed{% endif %}" href="{% url 'runs_list' %}?status=failed&review=needed"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></a>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
@@ -48,28 +48,32 @@
|
|||||||
{% if counts.failed_runs or counts.warning_runs or counts.running_runs or counts.queued_runs %}
|
{% if counts.failed_runs or counts.warning_runs or counts.running_runs or counts.queued_runs %}
|
||||||
<div class="status-overview">
|
<div class="status-overview">
|
||||||
{% if counts.failed_runs %}
|
{% if counts.failed_runs %}
|
||||||
<div class="status-summary failed">
|
<a class="status-summary failed" href="{% url 'runs_list' %}?status=failed&review=needed">
|
||||||
<span class="status failed">failed</span>
|
<span class="status failed">failed</span>
|
||||||
<strong>{{ counts.failed_runs }} failed run{{ counts.failed_runs|pluralize }} need{{ counts.failed_runs|pluralize:"s," }} review.</strong>
|
<strong>{{ counts.failed_runs }} failed run{{ counts.failed_runs|pluralize }} need{{ counts.failed_runs|pluralize:"s," }} review.</strong>
|
||||||
</div>
|
<span class="summary-action">Review failed runs</span>
|
||||||
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if counts.warning_runs %}
|
{% if counts.warning_runs %}
|
||||||
<div class="status-summary warning">
|
<a class="status-summary warning" href="{% url 'runs_list' %}?status=warning&review=needed">
|
||||||
<span class="status warning">warning</span>
|
<span class="status warning">warning</span>
|
||||||
<strong>{{ counts.warning_runs }} run{{ counts.warning_runs|pluralize }} completed with warnings.</strong>
|
<strong>{{ counts.warning_runs }} run{{ counts.warning_runs|pluralize }} completed with warnings.</strong>
|
||||||
</div>
|
<span class="summary-action">Review warnings</span>
|
||||||
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if counts.running_runs %}
|
{% if counts.running_runs %}
|
||||||
<div class="status-summary running">
|
<a class="status-summary running" href="{% url 'runs_list' %}?status=running">
|
||||||
<span class="status running">running</span>
|
<span class="status running">running</span>
|
||||||
<strong>{{ counts.running_runs }} backup run{{ counts.running_runs|pluralize }} in progress.</strong>
|
<strong>{{ counts.running_runs }} backup run{{ counts.running_runs|pluralize }} in progress.</strong>
|
||||||
</div>
|
<span class="summary-action">View running runs</span>
|
||||||
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if counts.queued_runs %}
|
{% if counts.queued_runs %}
|
||||||
<div class="status-summary queued">
|
<a class="status-summary queued" href="{% url 'runs_list' %}?status=queued">
|
||||||
<span class="status queued">queued</span>
|
<span class="status queued">queued</span>
|
||||||
<strong>{{ counts.queued_runs }} backup run{{ counts.queued_runs|pluralize }} waiting for the worker.</strong>
|
<strong>{{ counts.queued_runs }} backup run{{ counts.queued_runs|pluralize }} waiting for the worker.</strong>
|
||||||
</div>
|
<span class="summary-action">View queued runs</span>
|
||||||
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% elif counts.hosts %}
|
{% elif counts.hosts %}
|
||||||
@@ -79,7 +83,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="hosts">
|
||||||
<h2>Backup Trends</h2>
|
<h2>Backup Trends</h2>
|
||||||
{% if stats_summary.runs_sampled %}
|
{% if stats_summary.runs_sampled %}
|
||||||
<div class="insight-grid" aria-label="Backup trends">
|
<div class="insight-grid" aria-label="Backup trends">
|
||||||
@@ -266,7 +270,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Latest Runs</h2>
|
<h2>Latest Runs <a class="button-link secondary" href="{% url 'runs_list' %}">View all</a></h2>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
106
src/pobsync_backend/templates/pobsync_backend/runs_list.html
Normal file
106
src/pobsync_backend/templates/pobsync_backend/runs_list.html
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Runs | pobsync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<header class="page-header">
|
||||||
|
<div class="page-title">
|
||||||
|
<div class="page-kicker">Activity</div>
|
||||||
|
<h1>Runs</h1>
|
||||||
|
<div class="page-subtitle">Review queued, running, completed, warning, failed, and cancelled backup runs.</div>
|
||||||
|
</div>
|
||||||
|
<section class="actions" aria-label="Run list actions">
|
||||||
|
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||||
|
</section>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Filters</h2>
|
||||||
|
<form method="get" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="status">Status</label>
|
||||||
|
<select id="status" name="status">
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
{% for value, label in statuses %}
|
||||||
|
<option value="{{ value }}" {% if selected_status == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="type">Type</label>
|
||||||
|
<select id="type" name="type">
|
||||||
|
<option value="">All types</option>
|
||||||
|
{% for value, label in run_types %}
|
||||||
|
<option value="{{ value }}" {% if selected_type == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="host">Host</label>
|
||||||
|
<select id="host" name="host">
|
||||||
|
<option value="">All hosts</option>
|
||||||
|
{% for host in hosts %}
|
||||||
|
<option value="{{ host.host }}" {% if selected_host == host.host %}selected{% endif %}>{{ host.host }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="review">Review</label>
|
||||||
|
<select id="review" name="review">
|
||||||
|
<option value="">All review states</option>
|
||||||
|
<option value="needed" {% if selected_review == "needed" %}selected{% endif %}>Needs review</option>
|
||||||
|
<option value="reviewed" {% if selected_review == "reviewed" %}selected{% endif %}>Reviewed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Apply filters</button>
|
||||||
|
<a class="button-link secondary" href="{% url 'runs_list' %}">Clear</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Backup Runs</h2>
|
||||||
|
<p class="muted">Showing up to 200 of {{ total_count }} run{{ total_count|pluralize }}.</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Run</th>
|
||||||
|
<th>Host</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Ended</th>
|
||||||
|
<th>Snapshot</th>
|
||||||
|
<th>Review</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for run in runs %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{% url 'run_detail' run.id %}">Run {{ run.id }}</a></td>
|
||||||
|
<td><a href="{% url 'host_detail' run.host.host %}">{{ run.host.host }}</a></td>
|
||||||
|
<td><span class="status {{ run.status }}">{{ run.status }}</span></td>
|
||||||
|
<td>{{ run.run_type }}</td>
|
||||||
|
<td>{{ run.created_at }}</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>
|
||||||
|
{% elif run.snapshot_path %}
|
||||||
|
<span class="muted">{{ run.snapshot_path }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="muted">none</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{% if run.reviewed_at %}reviewed{% elif run.status == "failed" or run.status == "warning" %}<span class="status warning">needed</span>{% else %}<span class="muted">none</span>{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="9" class="muted">No runs matched the current filter.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Schedules | pobsync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<header class="page-header">
|
||||||
|
<div class="page-title">
|
||||||
|
<div class="page-kicker">Scheduler</div>
|
||||||
|
<h1>Schedules</h1>
|
||||||
|
<div class="page-subtitle">Review configured backup schedules, next run times, prune settings, and recent scheduler state.</div>
|
||||||
|
</div>
|
||||||
|
<section class="actions" aria-label="Schedule list actions">
|
||||||
|
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||||
|
</section>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Filters</h2>
|
||||||
|
<form method="get" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="host">Host</label>
|
||||||
|
<select id="host" name="host">
|
||||||
|
<option value="">All hosts</option>
|
||||||
|
{% for host in hosts %}
|
||||||
|
<option value="{{ host.host }}" {% if selected_host == host.host %}selected{% endif %}>{{ host.host }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="enabled">Enabled</label>
|
||||||
|
<select id="enabled" name="enabled">
|
||||||
|
<option value="">All schedules</option>
|
||||||
|
<option value="yes" {% if selected_enabled == "yes" %}selected{% endif %}>Enabled</option>
|
||||||
|
<option value="no" {% if selected_enabled == "no" %}selected{% endif %}>Disabled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="prune">Prune</label>
|
||||||
|
<select id="prune" name="prune">
|
||||||
|
<option value="">All prune states</option>
|
||||||
|
<option value="yes" {% if selected_prune == "yes" %}selected{% endif %}>Prune enabled</option>
|
||||||
|
<option value="no" {% if selected_prune == "no" %}selected{% endif %}>Prune disabled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Apply filters</button>
|
||||||
|
<a class="button-link secondary" href="{% url 'schedules_list' %}">Clear</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Configured Schedules</h2>
|
||||||
|
<p class="muted">Showing up to 200 of {{ total_count }} schedule{{ total_count|pluralize }}. Times use {{ scheduler_timezone }}.</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Host</th>
|
||||||
|
<th>Expression</th>
|
||||||
|
<th>Enabled</th>
|
||||||
|
<th>Next Run</th>
|
||||||
|
<th>Prune</th>
|
||||||
|
<th>Last Status</th>
|
||||||
|
<th>Last Started</th>
|
||||||
|
<th>Last Finished</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in schedule_rows %}
|
||||||
|
{% with schedule=row.schedule %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{% url 'host_detail' schedule.host.host %}">{{ schedule.host.host }}</a></td>
|
||||||
|
<td><code>{{ schedule.cron_expr }}</code></td>
|
||||||
|
<td><span class="status {% if schedule.enabled %}ok{% else %}skipped{% endif %}">{{ schedule.enabled|yesno:"enabled,disabled" }}</span></td>
|
||||||
|
<td>
|
||||||
|
{% if row.next_run_at %}
|
||||||
|
{{ row.next_run_at|date:"Y-m-d H:i T" }}
|
||||||
|
{% else %}
|
||||||
|
<span class="muted">none</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="status {% if schedule.prune %}ok{% else %}skipped{% endif %}">{{ schedule.prune|yesno:"enabled,disabled" }}</span>
|
||||||
|
{% if schedule.prune %}
|
||||||
|
<div class="muted">max {{ schedule.prune_max_delete }}{% if schedule.prune_protect_bases %}, protects bases{% endif %}</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{% if schedule.last_status %}<span class="status {{ schedule.last_status }}">{{ schedule.last_status }}</span>{% else %}<span class="muted">none</span>{% endif %}</td>
|
||||||
|
<td>{{ schedule.last_started_at|default:"" }}</td>
|
||||||
|
<td>{{ schedule.last_finished_at|default:"" }}</td>
|
||||||
|
<td><a class="button-link secondary" href="{% url 'edit_host_schedule' schedule.host.host %}">Edit</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endwith %}
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="9" class="muted">No schedules matched the current filter.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Snapshots | pobsync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<header class="page-header">
|
||||||
|
<div class="page-title">
|
||||||
|
<div class="page-kicker">Snapshots</div>
|
||||||
|
<h1>Snapshots</h1>
|
||||||
|
<div class="page-subtitle">Browse discovered scheduled, manual, and incomplete snapshots across all hosts.</div>
|
||||||
|
</div>
|
||||||
|
<section class="actions" aria-label="Snapshot list actions">
|
||||||
|
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||||
|
</section>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Filters</h2>
|
||||||
|
<form method="get" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="host">Host</label>
|
||||||
|
<select id="host" name="host">
|
||||||
|
<option value="">All hosts</option>
|
||||||
|
{% for host in hosts %}
|
||||||
|
<option value="{{ host.host }}" {% if selected_host == host.host %}selected{% endif %}>{{ host.host }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="kind">Kind</label>
|
||||||
|
<select id="kind" name="kind">
|
||||||
|
<option value="">All kinds</option>
|
||||||
|
{% for value, label in kinds %}
|
||||||
|
<option value="{{ value }}" {% if selected_kind == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="status">Status</label>
|
||||||
|
<select id="status" name="status">
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
{% for value in statuses %}
|
||||||
|
<option value="{{ value }}" {% if selected_status == value %}selected{% endif %}>{{ value }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Apply filters</button>
|
||||||
|
<a class="button-link secondary" href="{% url 'snapshots_list' %}">Clear</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Snapshot Records</h2>
|
||||||
|
<p class="muted">Showing up to 200 of {{ total_count }} snapshot{{ total_count|pluralize }}.</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Snapshot</th>
|
||||||
|
<th>Host</th>
|
||||||
|
<th>Kind</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Ended</th>
|
||||||
|
<th>Base</th>
|
||||||
|
<th>Path</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for snapshot in snapshots %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{% url 'snapshot_detail' snapshot.id %}">{{ snapshot.dirname }}</a></td>
|
||||||
|
<td><a href="{% url 'host_detail' snapshot.host.host %}">{{ snapshot.host.host }}</a></td>
|
||||||
|
<td>{{ snapshot.kind }}</td>
|
||||||
|
<td>{% if snapshot.status %}<span class="status {{ snapshot.status }}">{{ snapshot.status }}</span>{% else %}<span class="muted">unknown</span>{% endif %}</td>
|
||||||
|
<td>{{ snapshot.started_at|default:"" }}</td>
|
||||||
|
<td>{{ snapshot.ended_at|default:"" }}</td>
|
||||||
|
<td>
|
||||||
|
{% if snapshot.base %}
|
||||||
|
<a href="{% url 'snapshot_detail' snapshot.base.id %}">{{ snapshot.base.dirname }}</a>
|
||||||
|
{% elif snapshot.base_dirname %}
|
||||||
|
<span class="muted">{{ snapshot.base_dirname }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="muted">none</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="muted">{{ snapshot.path }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="8" class="muted">No snapshots matched the current filter.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -125,6 +125,17 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "1 run completed with warnings.")
|
self.assertContains(response, "1 run completed with warnings.")
|
||||||
self.assertContains(response, "1 backup run in progress.")
|
self.assertContains(response, "1 backup run in progress.")
|
||||||
self.assertContains(response, "1 backup run waiting for the worker.")
|
self.assertContains(response, "1 backup run waiting for the worker.")
|
||||||
|
self.assertContains(response, "Review failed runs")
|
||||||
|
self.assertContains(response, "Review warnings")
|
||||||
|
self.assertContains(response, "View running runs")
|
||||||
|
self.assertContains(response, "View queued runs")
|
||||||
|
self.assertContains(response, f'href="{reverse("runs_list")}"', html=False)
|
||||||
|
self.assertContains(response, f'href="{reverse("runs_list")}?status=queued"', html=False)
|
||||||
|
self.assertContains(response, f'href="{reverse("runs_list")}?status=running"', html=False)
|
||||||
|
self.assertContains(response, f'href="{reverse("runs_list")}?status=warning&review=needed"', html=False)
|
||||||
|
self.assertContains(response, f'href="{reverse("runs_list")}?status=failed&review=needed"', html=False)
|
||||||
|
self.assertContains(response, f'href="{reverse("snapshots_list")}"', html=False)
|
||||||
|
self.assertContains(response, f'href="{reverse("schedules_list")}"', html=False)
|
||||||
|
|
||||||
def test_dashboard_renders_backup_trend_summary(self) -> None:
|
def test_dashboard_renders_backup_trend_summary(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
@@ -200,6 +211,63 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "Operational Status")
|
self.assertContains(response, "Operational Status")
|
||||||
self.assertContains(response, "No queued, running, or unreviewed warning/failed runs.")
|
self.assertContains(response, "No queued, running, or unreviewed warning/failed runs.")
|
||||||
|
|
||||||
|
def test_runs_list_filters_by_status_and_review(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
web = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
db = HostConfig.objects.create(host="db-01", address="db-01.example.test")
|
||||||
|
failed = BackupRun.objects.create(host=web, status=BackupRun.Status.FAILED, run_type=BackupRun.RunType.MANUAL)
|
||||||
|
success = BackupRun.objects.create(host=db, status=BackupRun.Status.SUCCESS, run_type=BackupRun.RunType.SCHEDULED)
|
||||||
|
BackupRun.objects.create(
|
||||||
|
host=web,
|
||||||
|
status=BackupRun.Status.WARNING,
|
||||||
|
reviewed_at=datetime(2026, 5, 19, 4, 15, tzinfo=timezone.utc),
|
||||||
|
reviewed_by="admin",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("runs_list"), {"status": "failed", "review": "needed"})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Runs")
|
||||||
|
self.assertContains(response, "Review queued, running, completed")
|
||||||
|
self.assertContains(response, f"Run {failed.id}")
|
||||||
|
self.assertContains(response, "web-01")
|
||||||
|
self.assertContains(response, "needed")
|
||||||
|
self.assertNotContains(response, f"Run {success.id}")
|
||||||
|
|
||||||
|
def test_snapshots_list_filters_by_host_and_kind(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
web = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
db = HostConfig.objects.create(host="db-01", address="db-01.example.test")
|
||||||
|
manual = self._snapshot(web, "20260519-021500Z__MANUAL01", kind=SnapshotRecord.Kind.MANUAL)
|
||||||
|
scheduled = self._snapshot(db, "20260519-021500Z__SCHED01", kind=SnapshotRecord.Kind.SCHEDULED)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("snapshots_list"), {"host": web.host, "kind": SnapshotRecord.Kind.MANUAL})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Snapshots")
|
||||||
|
self.assertContains(response, "Browse discovered scheduled, manual, and incomplete snapshots")
|
||||||
|
self.assertContains(response, manual.dirname)
|
||||||
|
self.assertContains(response, "web-01")
|
||||||
|
self.assertNotContains(response, scheduled.dirname)
|
||||||
|
|
||||||
|
def test_schedules_list_filters_by_enabled_and_prune(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
web = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
db = HostConfig.objects.create(host="db-01", address="db-01.example.test")
|
||||||
|
ScheduleConfig.objects.create(host=web, cron_expr="15 2 * * *", enabled=True, prune=True, last_status="success")
|
||||||
|
ScheduleConfig.objects.create(host=db, cron_expr="30 3 * * *", enabled=False, prune=False, last_status="failed")
|
||||||
|
|
||||||
|
response = self.client.get(reverse("schedules_list"), {"enabled": "yes", "prune": "yes"})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Schedules")
|
||||||
|
self.assertContains(response, "Review configured backup schedules")
|
||||||
|
self.assertContains(response, "web-01")
|
||||||
|
self.assertContains(response, "15 2 * * *")
|
||||||
|
self.assertContains(response, "success")
|
||||||
|
self.assertContains(response, "UTC")
|
||||||
|
self.assertNotContains(response, "30 3 * * *")
|
||||||
|
|
||||||
def test_dashboard_surfaces_retention_warnings(self) -> None:
|
def test_dashboard_surfaces_retention_warnings(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
host = HostConfig.objects.create(
|
host = HostConfig.objects.create(
|
||||||
@@ -2223,13 +2291,19 @@ class ViewTests(TestCase):
|
|||||||
self.assertEqual(host.excludes_add, [])
|
self.assertEqual(host.excludes_add, [])
|
||||||
self.assertEqual(host.excludes_replace, ["*.cache", "node_modules/"])
|
self.assertEqual(host.excludes_replace, ["*.cache", "node_modules/"])
|
||||||
|
|
||||||
def _snapshot(self, host: HostConfig, dirname: str) -> SnapshotRecord:
|
def _snapshot(
|
||||||
|
self,
|
||||||
|
host: HostConfig,
|
||||||
|
dirname: str,
|
||||||
|
*,
|
||||||
|
kind: str = SnapshotRecord.Kind.SCHEDULED,
|
||||||
|
) -> SnapshotRecord:
|
||||||
started_at = datetime.strptime(dirname.split("__", 1)[0], "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc)
|
started_at = datetime.strptime(dirname.split("__", 1)[0], "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc)
|
||||||
return SnapshotRecord.objects.create(
|
return SnapshotRecord.objects.create(
|
||||||
host=host,
|
host=host,
|
||||||
kind=SnapshotRecord.Kind.SCHEDULED,
|
kind=kind,
|
||||||
dirname=dirname,
|
dirname=dirname,
|
||||||
path=f"/backups/{host.host}/scheduled/{dirname}",
|
path=f"/backups/{host.host}/{kind}/{dirname}",
|
||||||
status="success",
|
status="success",
|
||||||
started_at=started_at,
|
started_at=started_at,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -145,6 +145,102 @@ def logs(request):
|
|||||||
return render(request, "pobsync_backend/logs.html", context)
|
return render(request, "pobsync_backend/logs.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
def runs_list(request):
|
||||||
|
status = request.GET.get("status", "").strip()
|
||||||
|
run_type = request.GET.get("type", "").strip()
|
||||||
|
host = request.GET.get("host", "").strip()
|
||||||
|
review = request.GET.get("review", "").strip()
|
||||||
|
runs = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at", "-id")
|
||||||
|
if status:
|
||||||
|
runs = runs.filter(status=status)
|
||||||
|
if run_type:
|
||||||
|
runs = runs.filter(run_type=run_type)
|
||||||
|
if host:
|
||||||
|
runs = runs.filter(host__host=host)
|
||||||
|
if review == "needed":
|
||||||
|
runs = runs.filter(status__in=[BackupRun.Status.FAILED, BackupRun.Status.WARNING], reviewed_at__isnull=True)
|
||||||
|
elif review == "reviewed":
|
||||||
|
runs = runs.filter(reviewed_at__isnull=False)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"runs": runs[:200],
|
||||||
|
"total_count": runs.count(),
|
||||||
|
"hosts": HostConfig.objects.order_by("host"),
|
||||||
|
"statuses": BackupRun.Status.choices,
|
||||||
|
"run_types": BackupRun.RunType.choices,
|
||||||
|
"selected_status": status,
|
||||||
|
"selected_type": run_type,
|
||||||
|
"selected_host": host,
|
||||||
|
"selected_review": review,
|
||||||
|
}
|
||||||
|
return render(request, "pobsync_backend/runs_list.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
def snapshots_list(request):
|
||||||
|
kind = request.GET.get("kind", "").strip()
|
||||||
|
status = request.GET.get("status", "").strip()
|
||||||
|
host = request.GET.get("host", "").strip()
|
||||||
|
snapshots = SnapshotRecord.objects.select_related("host", "base").order_by("-started_at", "-discovered_at", "-id")
|
||||||
|
if kind:
|
||||||
|
snapshots = snapshots.filter(kind=kind)
|
||||||
|
if status:
|
||||||
|
snapshots = snapshots.filter(status=status)
|
||||||
|
if host:
|
||||||
|
snapshots = snapshots.filter(host__host=host)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"snapshots": snapshots[:200],
|
||||||
|
"total_count": snapshots.count(),
|
||||||
|
"hosts": HostConfig.objects.order_by("host"),
|
||||||
|
"kinds": SnapshotRecord.Kind.choices,
|
||||||
|
"statuses": SnapshotRecord.objects.exclude(status="").order_by("status").values_list("status", flat=True).distinct(),
|
||||||
|
"selected_kind": kind,
|
||||||
|
"selected_status": status,
|
||||||
|
"selected_host": host,
|
||||||
|
}
|
||||||
|
return render(request, "pobsync_backend/snapshots_list.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
def schedules_list(request):
|
||||||
|
enabled = request.GET.get("enabled", "").strip()
|
||||||
|
prune = request.GET.get("prune", "").strip()
|
||||||
|
host = request.GET.get("host", "").strip()
|
||||||
|
schedules = ScheduleConfig.objects.select_related("host").order_by("host__host")
|
||||||
|
if enabled == "yes":
|
||||||
|
schedules = schedules.filter(enabled=True)
|
||||||
|
elif enabled == "no":
|
||||||
|
schedules = schedules.filter(enabled=False)
|
||||||
|
if prune == "yes":
|
||||||
|
schedules = schedules.filter(prune=True)
|
||||||
|
elif prune == "no":
|
||||||
|
schedules = schedules.filter(prune=False)
|
||||||
|
if host:
|
||||||
|
schedules = schedules.filter(host__host=host)
|
||||||
|
|
||||||
|
schedule_rows = []
|
||||||
|
for schedule in schedules[:200]:
|
||||||
|
schedule_rows.append(
|
||||||
|
{
|
||||||
|
"schedule": schedule,
|
||||||
|
"next_run_at": _next_run_for_schedule(schedule, schedule.host),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"schedule_rows": schedule_rows,
|
||||||
|
"total_count": schedules.count(),
|
||||||
|
"hosts": HostConfig.objects.order_by("host"),
|
||||||
|
"selected_enabled": enabled,
|
||||||
|
"selected_prune": prune,
|
||||||
|
"selected_host": host,
|
||||||
|
"scheduler_timezone": timezone.get_current_timezone_name(),
|
||||||
|
}
|
||||||
|
return render(request, "pobsync_backend/schedules_list.html", context)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@staff_member_required
|
||||||
def purged_snapshots(request):
|
def purged_snapshots(request):
|
||||||
host = request.GET.get("host", "").strip()
|
host = request.GET.get("host", "").strip()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ urlpatterns = [
|
|||||||
path("self-check/", views.self_check, name="self_check"),
|
path("self-check/", views.self_check, name="self_check"),
|
||||||
path("logs/", views.logs, name="logs"),
|
path("logs/", views.logs, name="logs"),
|
||||||
path("purged-snapshots/", views.purged_snapshots, name="purged_snapshots"),
|
path("purged-snapshots/", views.purged_snapshots, name="purged_snapshots"),
|
||||||
|
path("schedules/", views.schedules_list, name="schedules_list"),
|
||||||
path("config/global/", views.edit_global_config, name="edit_global_config"),
|
path("config/global/", views.edit_global_config, name="edit_global_config"),
|
||||||
path("ssh-credentials/", views.ssh_credentials, name="ssh_credentials"),
|
path("ssh-credentials/", views.ssh_credentials, name="ssh_credentials"),
|
||||||
path("ssh-credentials/new/", views.create_ssh_credential, name="create_ssh_credential"),
|
path("ssh-credentials/new/", views.create_ssh_credential, name="create_ssh_credential"),
|
||||||
@@ -34,6 +35,7 @@ urlpatterns = [
|
|||||||
name="cleanup_host_incomplete_snapshots",
|
name="cleanup_host_incomplete_snapshots",
|
||||||
),
|
),
|
||||||
path("hosts/<str:host>/schedule/", views.edit_host_schedule, name="edit_host_schedule"),
|
path("hosts/<str:host>/schedule/", views.edit_host_schedule, name="edit_host_schedule"),
|
||||||
|
path("runs/", views.runs_list, name="runs_list"),
|
||||||
path("runs/<int:run_id>/", views.run_detail, name="run_detail"),
|
path("runs/<int:run_id>/", views.run_detail, name="run_detail"),
|
||||||
path("runs/<int:run_id>/rsync-log/", views.run_rsync_log, name="run_rsync_log"),
|
path("runs/<int:run_id>/rsync-log/", views.run_rsync_log, name="run_rsync_log"),
|
||||||
path("runs/<int:run_id>/cancel/", views.cancel_run, name="cancel_run"),
|
path("runs/<int:run_id>/cancel/", views.cancel_run, name="cancel_run"),
|
||||||
@@ -43,6 +45,7 @@ urlpatterns = [
|
|||||||
views.resolve_host_incomplete_reviews,
|
views.resolve_host_incomplete_reviews,
|
||||||
name="resolve_host_incomplete_reviews",
|
name="resolve_host_incomplete_reviews",
|
||||||
),
|
),
|
||||||
|
path("snapshots/", views.snapshots_list, name="snapshots_list"),
|
||||||
path("snapshots/<int:snapshot_id>/", views.snapshot_detail, name="snapshot_detail"),
|
path("snapshots/<int:snapshot_id>/", views.snapshot_detail, name="snapshot_detail"),
|
||||||
path("api/", api.api_index),
|
path("api/", api.api_index),
|
||||||
path("api/status/", api.status),
|
path("api/status/", api.status),
|
||||||
|
|||||||
Reference in New Issue
Block a user