- #5304 "Docker disk totals recorded over time per host, with a growth chart and alertable metrics" - #5305 "Disk page: images grouped by repository, versions folded under it" - #5306 "Docker overview: one disk line per host linking to the Disk page" Each `docker system df` report is now also recorded as metrics, with the host as the resource: disk_total_bytes, its parts, and disk_reclaimable_bytes. An alert rule can watch them. The Disk page starts with the hosts side by side, showing how much each grew over the selected range with a line of it, then gives each host its own section. An image's versions fold under its repository, which is charged only for the layers its versions don't share. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
82 lines
4.2 KiB
HTML
82 lines
4.2 KiB
HTML
{% extends "base.html" %}
|
||
{% from "_macros.html" import page_header %}
|
||
{# The Docker page. The filter bar sits outside the polled fragment, so a poll
|
||
never resets what is being typed; the fragment reads the bar on every poll,
|
||
and a change re-fetches it and puts the filters in the URL (#5299). #}
|
||
{% block title %}Docker — Steward{% endblock %}
|
||
{% block content %}
|
||
{% call page_header("Docker", "What needs attention first, then every service and container.") %}
|
||
{% if has_swarm %}<a class="btn btn-sm btn-ghost" href="/plugins/docker/swarm">Swarm</a>{% endif %}
|
||
{% if has_disk %}<a class="btn btn-sm btn-ghost" href="/plugins/docker/disk">Disk</a>{% endif %}
|
||
{% endcall %}
|
||
|
||
<form id="docker-filters" class="filter-bar" method="get" action="/plugins/docker/" role="search">
|
||
<input type="search" id="docker-q" name="q" value="{{ f.q }}" class="input"
|
||
placeholder="Search name, image or stack" aria-label="Search services and containers" autocomplete="off">
|
||
{% if choices.stacks %}
|
||
<select name="stack" aria-label="Stack">
|
||
<option value="">All stacks</option>
|
||
{% for s in choices.stacks %}<option value="{{ s }}"{% if f.stack == s %} selected{% endif %}>{{ s }}</option>{% endfor %}
|
||
<option value="{{ no_stack }}"{% if f.stack == no_stack %} selected{% endif %}>No stack</option>
|
||
</select>
|
||
{% endif %}
|
||
{% if choices.hosts|length > 1 %}
|
||
<select name="host" aria-label="Host or node">
|
||
<option value="">All hosts</option>
|
||
{% for h in choices.hosts %}<option value="{{ h }}"{% if f.host == h %} selected{% endif %}>{{ h }}</option>{% endfor %}
|
||
</select>
|
||
{% endif %}
|
||
<select name="state" aria-label="Status">
|
||
<option value="">Any status</option>
|
||
{% for key, label in states.items() %}<option value="{{ key }}"{% if f.state == key %} selected{% endif %}>{{ label }}</option>{% endfor %}
|
||
</select>
|
||
{% if f.sort != 'status' %}<input type="hidden" name="sort" value="{{ f.sort }}"><input type="hidden" name="dir" value="{{ f.dir }}">{% endif %}
|
||
<noscript><button type="submit" class="btn btn-sm">Filter</button></noscript>
|
||
</form>
|
||
|
||
<div id="docker-rows"
|
||
hx-get="/plugins/docker/rows"
|
||
hx-include="#docker-filters"
|
||
hx-trigger="load, every {{ poll_interval }}s, change from:#docker-filters, input changed delay:250ms from:#docker-q, search from:#docker-q"
|
||
hx-swap="innerHTML">
|
||
<div class="card"><p class="field-hint">Loading…</p></div>
|
||
</div>
|
||
{% if disk %}
|
||
{# What Docker keeps on each host's disk, and its growth over a week (#5306). #}
|
||
<p class="docker-disk-line">
|
||
<a href="/plugins/docker/disk" class="section-title">Disk</a>
|
||
{% for d in disk %}
|
||
<span class="docker-disk-host"><a href="/plugins/docker/disk#host-{{ d.host_id }}">{{ d.host_name }}</a>
|
||
{{ d.total|bytes }}
|
||
{%- if d.growth and d.growth.change %} <span class="tone-{{ 'warn' if d.growth.pct is not none and d.growth.pct >= 10 else 'muted' }}"
|
||
title="Since {{ d.growth.since.strftime('%Y-%m-%d %H:%M UTC') }}">{{ "+" if d.growth.change > 0 else "−" }}{{ (d.growth.change|abs)|bytes }} in 7d</span>{% endif -%}
|
||
{%- if d.reclaimable %} <span class="tone-dim">· {{ d.reclaimable|bytes }} reclaimable</span>{% endif %}</span>
|
||
{% endfor %}
|
||
</p>
|
||
{% endif %}
|
||
<script>
|
||
(function () {
|
||
var rows = document.getElementById('docker-rows');
|
||
var form = document.getElementById('docker-filters');
|
||
// The filters live in the URL too, so a filtered view can be bookmarked and
|
||
// Back returns to it.
|
||
function keepInUrl() {
|
||
var q = new URLSearchParams(new FormData(form));
|
||
Array.from(q.keys()).forEach(function (k) { if (!q.get(k)) q.delete(k); });
|
||
var s = q.toString();
|
||
history.replaceState(null, '', location.pathname + (s ? '?' + s : '') + location.hash);
|
||
}
|
||
form.addEventListener('change', keepInUrl);
|
||
form.addEventListener('input', keepInUrl);
|
||
// A link to a section (the dashboard's "failed (24h)" → #failures) lands
|
||
// before the rows exist; scroll to it once, when they first arrive.
|
||
if (!location.hash || !rows) return;
|
||
rows.addEventListener('htmx:afterSwap', function once() {
|
||
rows.removeEventListener('htmx:afterSwap', once);
|
||
var target = document.getElementById(location.hash.slice(1));
|
||
if (target) target.scrollIntoView({ block: 'start' });
|
||
});
|
||
})();
|
||
</script>
|
||
{% endblock %}
|