feat(docker): the service and container pages say each fact once (#5310)
Service page: - The header carries the verdict, mode, stack, image and when it was reported. 'In trouble since' shows only while the service is in trouble; once recovered, 'Last failed X ago · its logs'. - The failure card shows only while the service is down, degraded or crash-looping. - The tile strip and the kv card are gone. - One Tasks table: running tasks first with CPU, memory and since when, then Swarm's record. The node is named as its Steward host throughout, and 'N failed in 24h' sits in the caption. Container page: - Same shape: header verdict, a 'Last exit' card only while it is stopped after a failing exit, set-up and I/O in one kv card, and lifecycle as a table. - A swarm task's breadcrumb goes through its service. Both pages: - Resource history is the one fragment (_usage_history.html): now and peak in cores and bytes, drawn with a floor of 1 core / 1 GiB (sparkline_svg floor=), so idle noise draws flat. - The container page's old % sparklines, its private _sparkline and the unused bucket()/container_history() helpers are removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+91
-53
@@ -46,27 +46,6 @@ def _uptime(started_at: datetime | None) -> str | None:
|
||||
return None if secs is None or secs < 0 else human_duration(secs)
|
||||
|
||||
|
||||
def _sparkline(values: list[float], width: int = 80, height: int = 20) -> str:
|
||||
if len(values) < 2:
|
||||
return f'<svg width="{width}" height="{height}"></svg>'
|
||||
mn, mx = min(values), max(values)
|
||||
if mx == mn:
|
||||
mx = mn + 1.0
|
||||
step = width / (len(values) - 1)
|
||||
pts = []
|
||||
for i, v in enumerate(values):
|
||||
x = i * step
|
||||
y = height - (v - mn) / (mx - mn) * (height - 2) - 1
|
||||
pts.append(f"{x:.1f},{y:.1f}")
|
||||
poly = " ".join(pts)
|
||||
return (
|
||||
f'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}" '
|
||||
f'style="vertical-align:middle;">'
|
||||
f'<polyline points="{poly}" fill="none" stroke="#6060c0" stroke-width="1.5"/>'
|
||||
f'</svg>'
|
||||
)
|
||||
|
||||
|
||||
def _filters(args) -> dict:
|
||||
"""The Docker page's search, filters and sort from the query string
|
||||
(#5299): q, stack, host, state, sort, dir; anything unknown is dropped."""
|
||||
@@ -440,21 +419,23 @@ def host_panel_rows(rows: list) -> tuple[list, list]:
|
||||
running[HOST_PANEL_BUSIEST:] + stopped)
|
||||
|
||||
|
||||
# Glyph + colour per lifecycle event, so the timeline reads at a glance.
|
||||
# Glyph + tone per lifecycle event, so the timeline reads at a glance.
|
||||
_EVENT_STYLE = {
|
||||
"start": ("▲", "var(--green)"),
|
||||
"restart": ("↻", "var(--yellow)"),
|
||||
"stop": ("■", "var(--text-muted)"),
|
||||
"die": ("✕", "var(--red)"),
|
||||
"oom": ("☠", "var(--red)"),
|
||||
"health_change": ("◐", "var(--orange)"),
|
||||
"start": ("▲", "ok"),
|
||||
"restart": ("↻", "warn"),
|
||||
"stop": ("■", "muted"),
|
||||
"die": ("✕", "crit"),
|
||||
"oom": ("☠", "crit"),
|
||||
"health_change": ("◐", "warn"),
|
||||
}
|
||||
|
||||
|
||||
@docker_bp.get("/container/<host_id>/<name>")
|
||||
@require_role(UserRole.viewer)
|
||||
async def container_detail(host_id: str, name: str):
|
||||
"""Full detail page for one container: facts, resource history, lifecycle."""
|
||||
"""One container (#5310): a header that says whether it is well and what it
|
||||
is, why it stopped only while it has, what it moved since it started, its
|
||||
CPU and memory over time, and its lifecycle."""
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
c = await db.get(DockerContainer, (host_id, name))
|
||||
host = await db.get(Host, host_id)
|
||||
@@ -491,10 +472,11 @@ async def container_detail(host_id: str, name: str):
|
||||
blk_read=human_bytes(c.blk_read_bytes), blk_write=human_bytes(c.blk_write_bytes),
|
||||
events=[
|
||||
{"event": e.event, "detail": e.detail, "at": e.at,
|
||||
"glyph": _EVENT_STYLE.get(e.event, ("•", "var(--text-muted)"))[0],
|
||||
"colour": _EVENT_STYLE.get(e.event, ("•", "var(--text-muted)"))[1]}
|
||||
"glyph": _EVENT_STYLE.get(e.event, ("•", "muted"))[0],
|
||||
"tone": _EVENT_STYLE.get(e.event, ("•", "muted"))[1]}
|
||||
for e in events
|
||||
],
|
||||
died_at=next((e.at for e in events if e.event in ("die", "oom")), None),
|
||||
current_range=request.args.get("range", DEFAULT_RANGE),
|
||||
)
|
||||
|
||||
@@ -761,16 +743,35 @@ async def disk_prune(host_id: str):
|
||||
@docker_bp.get("/container/<host_id>/<name>/history")
|
||||
@require_role(UserRole.viewer)
|
||||
async def container_history(host_id: str, name: str):
|
||||
"""HTMX fragment: CPU + memory sparklines for the selected time range."""
|
||||
"""HTMX fragment: the container's CPU (cores) and memory over the
|
||||
selected range, with the latest and the peak (#5310)."""
|
||||
since, range_key = parse_range(request.args.get("range"))
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
cpu_hist, mem_hist = await docker_svc.container_history(db, host_id, name, since)
|
||||
series = await docker_svc.service_history(db, [(host_id, name)], since)
|
||||
return await _usage_history(series, range_key)
|
||||
|
||||
|
||||
# A history line is drawn against at least this much, so a container idling
|
||||
# near zero draws flat (UI & Design ruling 8, #5310).
|
||||
HISTORY_CPU_FLOOR = 1.0 # cores
|
||||
HISTORY_MEM_FLOOR = 1 << 30 # bytes
|
||||
|
||||
|
||||
async def _usage_history(series: list, range_key: str):
|
||||
"""The CPU and memory history fragment both detail pages load: the latest
|
||||
and the peak over a line that spans its half, scaled with a floor."""
|
||||
from steward.core.status import sparkline_svg
|
||||
cpu = [c / 100 for _, c, _ in series]
|
||||
mem = [m for _, _, m in series]
|
||||
return await render_template(
|
||||
"docker/_container_history.html",
|
||||
sparkline_cpu=_sparkline(cpu_hist, width=320, height=48),
|
||||
sparkline_mem=_sparkline(mem_hist, width=320, height=48),
|
||||
have_data=len(cpu_hist) >= 2,
|
||||
range_key=range_key,
|
||||
"docker/_usage_history.html", have_data=len(series) >= 2, range_key=range_key,
|
||||
cpu_now=cpu[-1] if cpu else None, cpu_peak=max(cpu) if cpu else None,
|
||||
mem_now=mem[-1] if mem else None, mem_peak=max(mem) if mem else None,
|
||||
cpu_floor=HISTORY_CPU_FLOOR, mem_floor=HISTORY_MEM_FLOOR,
|
||||
spark_cpu=sparkline_svg(cpu, height=48, stroke="currentColor", fill_width=True,
|
||||
floor=HISTORY_CPU_FLOOR),
|
||||
spark_mem=sparkline_svg(mem, height=48, stroke="currentColor", fill_width=True,
|
||||
floor=HISTORY_MEM_FLOOR),
|
||||
)
|
||||
|
||||
|
||||
@@ -822,7 +823,7 @@ def _task_rows(tasks: list[dict], node_hostname: dict) -> list[dict]:
|
||||
for t in tasks:
|
||||
nid = t.get("node_id") or ""
|
||||
out.append({
|
||||
"task_id": t.get("task_id") or "", "slot": str(t.get("slot") or ""),
|
||||
"task_id": t.get("task_id") or "", "slot": str(t.get("slot") or ""), "node_id": nid,
|
||||
"node": node_hostname.get(nid) or (nid[:12] if nid else "—"),
|
||||
"state": t.get("state") or "unknown", "desired": t.get("desired_state") or "",
|
||||
"exit_code": t.get("exit_code"), "why": t.get("error") or t.get("message") or "",
|
||||
@@ -832,6 +833,50 @@ def _task_rows(tasks: list[dict], node_hostname: dict) -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
def _service_tasks(name: str, tasks: list[dict], replicas: list[dict],
|
||||
node_host: dict, host_name: dict) -> list[dict]:
|
||||
"""One table for a service's tasks (#5310): the running ones first, each
|
||||
with what its container uses, then Swarm's record of the rest, newest
|
||||
first. A node reads as its Steward host's name wherever one is known, so a
|
||||
node is spelled one way down the table. A running replica Swarm's record
|
||||
does not list (an agent before 1.8.0) still gets a row, and so does a node
|
||||
running tasks with no Steward agent on it."""
|
||||
live, unmatched = {}, []
|
||||
for r in (r for r in replicas if not r["ghost"]):
|
||||
slot, tid = docker_svc.task_of(name, r["name"]) or ("", "")
|
||||
tid = r.get("task_id") or tid # the agent's own record of it, else its name's
|
||||
if tid:
|
||||
live[tid] = (slot, r)
|
||||
else:
|
||||
unmatched.append(r)
|
||||
|
||||
def place(node_id, host_id, fallback):
|
||||
hid = host_id or node_host.get(node_id)
|
||||
return hid, (host_name.get(hid) if hid else None) or fallback
|
||||
|
||||
rows = []
|
||||
for t in tasks:
|
||||
_, r = live.pop(t["task_id"], (None, None))
|
||||
hid, node = place(t["node_id"], r["host_id"] if r else None, t["node"])
|
||||
rows.append(dict(t, node=node, host_id=hid, container=r["name"] if r else None,
|
||||
usage=r["usage"] if r else None, health=r["health"] if r else None,
|
||||
at=(r["started_at"] or t["at"]) if r and t["current"] else t["at"]))
|
||||
for tid, (slot, r) in [*live.items(), *(("", ("", r)) for r in unmatched)]:
|
||||
rows.append({"task_id": tid, "slot": slot, "node_id": "", "node": r["host"],
|
||||
"host_id": r["host_id"], "state": r["status"], "desired": "",
|
||||
"exit_code": None, "why": "", "at": r["started_at"], "failed": False,
|
||||
"current": (r["status"] or "").lower() == "running", "container": r["name"],
|
||||
"usage": r["usage"], "health": r["health"]})
|
||||
listed = {t["node"] for t in tasks if t["current"]}
|
||||
for g in (r for r in replicas if r["ghost"] and r["host"] not in listed):
|
||||
rows.append({"task_id": "", "slot": "", "node_id": "", "node": g["host"], "host_id": None,
|
||||
"state": "running", "desired": "", "exit_code": None,
|
||||
"why": f"{g['count']} running on a node with no Steward agent: no figures",
|
||||
"at": None, "failed": False, "current": True, "container": None,
|
||||
"usage": None, "health": None})
|
||||
return sorted(rows, key=lambda r: not r["current"]) # stable: newest first within each
|
||||
|
||||
|
||||
@docker_bp.get("/service/<name>")
|
||||
@require_role(UserRole.viewer)
|
||||
async def service_detail(name: str):
|
||||
@@ -857,9 +902,11 @@ async def service_detail(name: str):
|
||||
replicas = []
|
||||
for r in (s["replicas"] if s else []):
|
||||
c = None if r["ghost"] else by_key.get((r["host_id"], r["name"]))
|
||||
replicas.append(dict(r, usage=docker_svc.usage(c, *capacity.get(r["host_id"], (None, None)))
|
||||
if c is not None else None,
|
||||
uptime=_uptime(c.started_at) if c is not None and c.status == "running" else None))
|
||||
replicas.append(dict(r, task_id=getattr(c, "task_id", None) if c is not None else None,
|
||||
usage=docker_svc.usage(c, *capacity.get(r["host_id"], (None, None)))
|
||||
if c is not None else None))
|
||||
node_host = {c.node_id: c.host_id for c in ctx["containers"] if c.node_id}
|
||||
table = _service_tasks(name, tasks, replicas, node_host, ctx["host_name"])
|
||||
# Every task Steward holds logs or events for, newest known first.
|
||||
known = {t["task_id"]: _task_label(t["slot"], t["task_id"]) for t in tasks if t["task_id"]}
|
||||
for _, cname in task_containers:
|
||||
@@ -868,7 +915,7 @@ async def service_detail(name: str):
|
||||
focus = request.args.get("task", "")
|
||||
return await render_template(
|
||||
"docker/service.html", name=name, service=s,
|
||||
row=service_row(s, now) if s else None, svc=newest, tasks=tasks, replicas=replicas,
|
||||
row=service_row(s, now) if s else None, svc=newest, tasks=table,
|
||||
task_choices=list(known.items()), focus_task=focus if focus in known else "",
|
||||
stack=stack_of(name), current_range=request.args.get("range", DEFAULT_RANGE),
|
||||
)
|
||||
@@ -879,20 +926,11 @@ async def service_detail(name: str):
|
||||
async def service_history_fragment(name: str):
|
||||
"""HTMX fragment: the service's CPU (cores) and memory, summed across its
|
||||
replicas, over the selected range, with the latest and the peak."""
|
||||
from steward.core.status import sparkline_svg
|
||||
since, range_key = parse_range(request.args.get("range"))
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
series = await docker_svc.service_history(
|
||||
db, await docker_svc.service_containers(db, name), since)
|
||||
cpu = [c / 100 for _, c, _ in series]
|
||||
mem = [m for _, _, m in series]
|
||||
return await render_template(
|
||||
"docker/_service_history.html", have_data=len(series) >= 2, range_key=range_key,
|
||||
cpu_now=cpu[-1] if cpu else None, cpu_peak=max(cpu) if cpu else None,
|
||||
mem_now=mem[-1] if mem else None, mem_peak=max(mem) if mem else None,
|
||||
spark_cpu=sparkline_svg(cpu, height=48, stroke="currentColor", fill_width=True),
|
||||
spark_mem=sparkline_svg(mem, height=48, stroke="currentColor", fill_width=True),
|
||||
)
|
||||
return await _usage_history(series, range_key)
|
||||
|
||||
|
||||
@docker_bp.get("/service/<name>/logs/lines")
|
||||
|
||||
@@ -213,38 +213,6 @@ async def host_capacity(db, host_ids) -> dict[str, tuple[int | None, int | None]
|
||||
return out
|
||||
|
||||
|
||||
def bucket(values: list[float], target: int = 40) -> list[float]:
|
||||
"""Bucket-average a series down to ~target points (DB-agnostic, in Python).
|
||||
|
||||
Replaces the old SQL strftime() bucketing, which was SQLite-only and broke
|
||||
on Postgres. Agent cadence is dense, so a multi-hour window is hundreds of
|
||||
rows — averaging into a readable point count keeps the sparkline's shape.
|
||||
"""
|
||||
n = len(values)
|
||||
if n <= target:
|
||||
return values
|
||||
size = (n + target - 1) // target
|
||||
out: list[float] = []
|
||||
for i in range(0, n, size):
|
||||
chunk = values[i:i + size]
|
||||
out.append(sum(chunk) / len(chunk))
|
||||
return out
|
||||
|
||||
|
||||
async def container_history(db, host_id: str, name: str, since) -> tuple[list, list]:
|
||||
"""Return (cpu_series, mem_series) sparkline-ready for one host's container."""
|
||||
rows = (await db.execute(
|
||||
select(DockerMetric.cpu_pct, DockerMetric.mem_pct)
|
||||
.where(DockerMetric.host_id == host_id)
|
||||
.where(DockerMetric.container_name == name)
|
||||
.where(DockerMetric.scraped_at >= since)
|
||||
.order_by(DockerMetric.scraped_at)
|
||||
)).all()
|
||||
cpu = bucket([r.cpu_pct or 0.0 for r in rows])
|
||||
mem = bucket([r.mem_pct or 0.0 for r in rows])
|
||||
return cpu, mem
|
||||
|
||||
|
||||
async def host_map(db, host_ids: set[str]) -> dict[str, Host]:
|
||||
if not host_ids:
|
||||
return {}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{# docker/_container_history.html — CPU/mem sparklines for the selected range #}
|
||||
{% if have_data %}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1.5rem;">
|
||||
<div>
|
||||
<div class="section-title" style="margin-bottom:0.4rem;">CPU %</div>
|
||||
{{ sparkline_cpu | safe }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="section-title" style="margin-bottom:0.4rem;">Memory %</div>
|
||||
{{ sparkline_mem | safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;">
|
||||
Not enough samples in this range yet — history fills in as the agent reports.
|
||||
</div>
|
||||
{% endif %}
|
||||
+7
-6
@@ -1,22 +1,23 @@
|
||||
{# docker/_service_history.html — a service's CPU and memory summed across its
|
||||
replicas over the selected range (#5302): the latest and the peak, over a
|
||||
line that spans its half. #}
|
||||
{# docker/_usage_history.html — CPU and memory over the selected range, for a
|
||||
service (summed across its replicas, #5302) or one container (#5310): the
|
||||
latest and the peak, over a line that spans its half. The line is drawn
|
||||
against at least cpu_floor / mem_floor, so idling near zero draws flat. #}
|
||||
{% from "docker/_usage.html" import cores %}
|
||||
{% if have_data %}
|
||||
<div class="history-pair">
|
||||
<div>
|
||||
<div class="section-title">CPU</div>
|
||||
<div style="font-variant-numeric:tabular-nums;">{{ cores(cpu_now) }} <span class="tone-dim">now · peak {{ cores(cpu_peak) }}</span></div>
|
||||
<div class="spark">{{ spark_cpu|safe }}</div>
|
||||
<div class="spark" title="Drawn from zero to its peak, or to {{ cores(cpu_floor)|striptags }} when the peak is lower">{{ spark_cpu|safe }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="section-title">Memory</div>
|
||||
<div style="font-variant-numeric:tabular-nums;">{{ mem_now|bytes }} <span class="tone-dim">now · peak {{ mem_peak|bytes }}</span></div>
|
||||
<div class="spark">{{ spark_mem|safe }}</div>
|
||||
<div class="spark" title="Drawn from zero to its peak, or to {{ mem_floor|bytes }} when the peak is lower">{{ spark_mem|safe }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="tone-muted" style="margin:0;font-size:0.85rem;">
|
||||
Not enough samples in the last {{ range_key }} yet. History fills in as the agents report its replicas.
|
||||
Not enough samples in the last {{ range_key }} yet. History fills in as the agent reports.
|
||||
</p>
|
||||
{% endif %}
|
||||
@@ -1,98 +1,94 @@
|
||||
{# docker/container_detail.html — full detail page for one container. A swarm
|
||||
task is titled by its service and slot, with the raw container name under
|
||||
it; CPU reads in cores and memory in bytes (see _usage.html). #}
|
||||
{# docker/container_detail.html — one container (#5310), shaped like a
|
||||
service's page: a header that says whether it is well and what it is; why
|
||||
it stopped, only while it has; what it moved since it started; its CPU and
|
||||
memory over time (the one home for what it uses now); its lifecycle.
|
||||
A swarm task is titled by its service and slot, the raw name under it. #}
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import crumbs %}
|
||||
{% from "docker/_usage.html" import cpu, mem %}
|
||||
{% from "_macros.html" import crumbs, status_badge %}
|
||||
{% block title %}{{ label or name }} — Docker — Steward{% endblock %}
|
||||
{% block breadcrumb %}{{ crumbs([("Docker", "/plugins/docker/"), (label or name, "")]) }}{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
{%- if container is not none and container.service_name -%}
|
||||
{{ crumbs([("Docker", "/plugins/docker/"), (container.service_name, "/plugins/docker/service/" ~ container.service_name), (label, "")]) }}
|
||||
{%- else -%}
|
||||
{{ crumbs([("Docker", "/plugins/docker/"), (label or name, "")]) }}
|
||||
{%- endif -%}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{% if container is none %}
|
||||
<div class="card" style="text-align:center;padding:3rem;">
|
||||
<div style="font-weight:500;margin-bottom:0.4rem;">Container not found</div>
|
||||
<div style="color:var(--text-muted);font-size:0.9rem;">
|
||||
No container named <code>{{ name }}</code> is currently reported for this host.
|
||||
It may have been removed, or the host agent hasn't reported recently.
|
||||
</div>
|
||||
<div class="card empty-state">
|
||||
<strong>Container not found</strong>
|
||||
<span>No container named <code>{{ name }}</code> is reported for this host now. It may have been
|
||||
removed, or the host agent has not reported recently. <a href="/plugins/docker/">Back to Docker</a></span>
|
||||
</div>
|
||||
{% else %}
|
||||
{% set c = container %}
|
||||
|
||||
{# ── Header ──────────────────────────────────────────────────────────────── #}
|
||||
<div style="display:flex;align-items:center;gap:0.6rem;margin-bottom:0.35rem;flex-wrap:wrap;">
|
||||
<span class="dot {% if container.status == 'running' %}dot-up{% elif container.status == 'paused' %}dot-warn{% else %}dot-down{% endif %}"></span>
|
||||
<h1 class="page-title" style="margin-bottom:0;">{{ label }}</h1>
|
||||
{% if container.health == 'healthy' %}<span style="font-size:0.72rem;color:var(--green);border:1px solid var(--green);border-radius:3px;padding:0.05rem 0.4rem;">healthy</span>
|
||||
{% elif container.health == 'unhealthy' %}<span style="font-size:0.72rem;color:var(--red);border:1px solid var(--red);border-radius:3px;padding:0.05rem 0.4rem;">unhealthy</span>
|
||||
{% elif container.health == 'starting' %}<span style="font-size:0.72rem;color:var(--orange);border:1px solid var(--orange);border-radius:3px;padding:0.05rem 0.4rem;">starting</span>{% endif %}
|
||||
</div>
|
||||
{% if label != container.name %}
|
||||
<div style="font-size:0.75rem;color:var(--text-dim);font-family:ui-monospace,monospace;margin-bottom:0.25rem;overflow-wrap:anywhere;">{{ container.name }}</div>
|
||||
{% endif %}
|
||||
<div style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.5rem;">
|
||||
{{ container.status }}{% if uptime %} · up {{ uptime }}{% endif %}
|
||||
{% if host %} · on <a href="/hosts/{{ host.id }}" style="color:var(--text-muted);">{{ host.name }}</a>{% endif %}
|
||||
· <a href="/plugins/docker/container/{{ host_id }}/{{ name }}/logs">View logs</a>
|
||||
</div>
|
||||
|
||||
{# ── Facts grid ──────────────────────────────────────────────────────────── #}
|
||||
{% macro fact(label, value, colour="") %}
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.3rem;">{{ label }}</div>
|
||||
<div style="font-size:0.95rem;{% if colour %}color:{{ colour }};{% endif %}font-family:ui-monospace,monospace;">{{ value }}</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.75rem;margin-bottom:1.5rem;">
|
||||
{{ fact("CPU", cpu(usage) if container.status == 'running' else "—") }}
|
||||
{{ fact("Memory", mem(usage) if container.status == 'running' else "—") }}
|
||||
{#- Swarm never restarts a task's container: it replaces the task with a new
|
||||
one, so restart_count is always 0 there and the service's task failures
|
||||
(Swarm page) are the count that means something. #}
|
||||
{% if not container.service_name %}
|
||||
{{ fact("Restarts", container.restart_count, "var(--orange)" if container.restart_count else "") }}
|
||||
{% endif %}
|
||||
{{ fact("Last exit", (container.exit_code ~ (" (OOM)" if container.oom_killed else "")) if container.exit_code is not none else "—",
|
||||
"var(--red)" if exit_failed else "") }}
|
||||
{{ fact("Net in since start", net_rx) }}
|
||||
{{ fact("Net out since start", net_tx) }}
|
||||
{{ fact("Disk read since start", blk_read) }}
|
||||
{{ fact("Disk written since start", blk_write) }}
|
||||
</div>
|
||||
{% if container.service_name %}
|
||||
<div style="font-size:0.78rem;color:var(--text-muted);margin:-0.75rem 0 1.5rem;">
|
||||
Swarm replaces a failed task with a new container rather than restarting it; the
|
||||
service's failed tasks are on the <a href="/plugins/docker/swarm">Swarm page</a>.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Image / placement ───────────────────────────────────────────────────── #}
|
||||
<div class="card" style="margin-bottom:1.5rem;">
|
||||
<div style="display:grid;grid-template-columns:max-content 1fr;gap:0.4rem 1.25rem;font-size:0.86rem;">
|
||||
<span style="color:var(--text-muted);">Image</span>
|
||||
<span style="font-family:ui-monospace,monospace;word-break:break-all;">{{ container.image or "—" }}</span>
|
||||
{% if container.compose_project %}
|
||||
<span style="color:var(--text-muted);">Compose project</span><span>{{ container.compose_project }}</span>
|
||||
{% endif %}
|
||||
{% if container.service_name %}
|
||||
<span style="color:var(--text-muted);">Swarm service</span><span>{{ container.service_name }}</span>
|
||||
{% endif %}
|
||||
{% if container.node_id %}
|
||||
<span style="color:var(--text-muted);">Node</span>
|
||||
{% if node_name %}<span title="node id {{ container.node_id }}">{{ node_name }}</span>
|
||||
{% else %}<span style="font-family:ui-monospace,monospace;">{{ container.node_id }}</span>{% endif %}
|
||||
{% endif %}
|
||||
{% if ports %}
|
||||
<span style="color:var(--text-muted);">Ports</span>
|
||||
<span style="font-family:ui-monospace,monospace;">{% for p in ports %}{{ p.host_port }}:{{ p.container_port }}/{{ p.protocol }}{% if not loop.last %}, {% endif %}{% endfor %}</span>
|
||||
{% endif %}
|
||||
{# ── Header: the verdict, then what it is ────────────────────────────────── #}
|
||||
<div class="page-header">
|
||||
<div style="min-width:0;">
|
||||
<h1 class="page-title" style="overflow-wrap:anywhere;">{{ label }}</h1>
|
||||
<p class="page-subtitle">
|
||||
{% if c.status == 'running' %}{{ status_badge('ok', 'Running') }}
|
||||
{% elif c.status == 'restarting' %}{{ status_badge('crit', 'Restarting') }}
|
||||
{% elif c.status == 'paused' %}{{ status_badge('warn', 'Paused') }}
|
||||
{% elif exit_failed %}{{ status_badge('crit', c.status|capitalize) }}
|
||||
{% else %}{{ status_badge('dim', c.status|capitalize) }}{% endif %}
|
||||
{%- if c.health == 'unhealthy' %} {{ status_badge('crit', 'Unhealthy') }}
|
||||
{%- elif c.health == 'starting' %} {{ status_badge('pending', 'Starting') }}
|
||||
{%- elif c.health == 'healthy' %} {{ status_badge('ok', 'Healthy') }}{% endif %}
|
||||
{%- if uptime %} · up {{ uptime }}{% endif %}
|
||||
{%- if host %} · on <a href="/hosts/{{ host.id }}">{{ host.name }}</a>{% endif %}
|
||||
{%- if c.service_name %} · task of <a href="/plugins/docker/service/{{ c.service_name }}">{{ c.service_name }}</a>
|
||||
{%- elif c.compose_project %} · compose <span class="chip">{{ c.compose_project }}</span>{% endif %}
|
||||
{%- if c.image %} · <span class="mono" style="overflow-wrap:anywhere;" title="{{ c.image }}">{{ c.image.split("@")[0] }}</span>{% endif %}
|
||||
</p>
|
||||
<p class="page-subtitle tone-dim" style="margin-top:0.2rem;">
|
||||
{%- if label != c.name %}<span class="mono" style="overflow-wrap:anywhere;">{{ c.name }}</span> · {% endif -%}
|
||||
{%- if c.scraped_at %}reported {{ c.scraped_at|ago }}{% endif -%}
|
||||
</p>
|
||||
</div>
|
||||
<div class="page-actions"><a class="btn btn-sm btn-ghost" href="/plugins/docker/container/{{ host_id }}/{{ name }}/logs">Logs</a></div>
|
||||
</div>
|
||||
|
||||
{# ── Resource history (range-toggled) ────────────────────────────────────── #}
|
||||
<div class="card" style="margin-bottom:1.5rem;">
|
||||
{# ── Why it stopped, only while it has ───────────────────────────────────── #}
|
||||
{% if c.status != 'running' and exit_failed %}
|
||||
<section class="card problem-card" aria-labelledby="ctr-exit">
|
||||
<h2 id="ctr-exit" class="section-title" style="margin-bottom:0.4rem;">Last exit</h2>
|
||||
<p style="margin:0;">
|
||||
Exited <span class="tone-crit">{{ c.exit_code }}</span>{% if c.oom_killed %}: killed for running out of memory{% endif %}
|
||||
{%- if died_at %} · {{ died_at|ago }}{% endif %}
|
||||
· <a href="/plugins/docker/container/{{ host_id }}/{{ name }}/logs">its logs →</a>
|
||||
</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{# ── What it is set up with, and what it moved since it started ─────────── #}
|
||||
<div class="card">
|
||||
<dl class="kv">
|
||||
{% if not c.service_name %}
|
||||
{#- Swarm never restarts a task's container: it replaces the task, so a
|
||||
task's restart count is always 0; its service's Tasks table is the
|
||||
count that means something. #}
|
||||
<dt>Restarts</dt><dd>{% if c.restart_count %}<span class="tone-warn">{{ c.restart_count }}</span>{% else %}0{% endif %}
|
||||
{%- if c.exit_code is not none and c.status == 'running' and c.restart_count %} <span class="tone-dim">· last exit {{ c.exit_code }}</span>{% endif %}</dd>
|
||||
{% endif %}
|
||||
<dt>Memory limit</dt><dd>{% if usage.mem_limit %}{{ usage.mem_limit|bytes }}{% else %}<span class="tone-dim">none set</span>{% endif %}</dd>
|
||||
{% if ports %}
|
||||
<dt>Ports</dt><dd class="mono">{% for p in ports %}{{ p.host_port }}:{{ p.container_port }}/{{ p.protocol }}{% if not loop.last %}, {% endif %}{% endfor %}</dd>
|
||||
{% endif %}
|
||||
{% if c.node_id %}
|
||||
<dt>Swarm node</dt><dd title="node id {{ c.node_id }}">{{ node_name or c.node_id[:12] }}</dd>
|
||||
{% endif %}
|
||||
<dt>Network</dt><dd>{{ net_rx }} in · {{ net_tx }} out <span class="tone-dim">since it started</span></dd>
|
||||
<dt>Disk</dt><dd>{{ blk_read }} read · {{ blk_write }} written <span class="tone-dim">since it started</span></dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{# ── Resource history: the one home for what it uses ────────────────────── #}
|
||||
<section class="card" aria-labelledby="ctr-history">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:1rem;margin-bottom:0.75rem;flex-wrap:wrap;">
|
||||
<h3 class="section-title" style="margin-bottom:0;">Resource history</h3>
|
||||
<h2 id="ctr-history" class="section-title" style="margin:0;">Resource history</h2>
|
||||
{% include "_time_range.html" %}
|
||||
</div>
|
||||
<div id="container-history"
|
||||
@@ -100,31 +96,30 @@
|
||||
hx-trigger="load, rangeChange from:body"
|
||||
hx-include="#time-range"
|
||||
hx-swap="innerHTML">
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;">Loading…</div>
|
||||
<p class="field-hint">Loading…</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# ── Lifecycle timeline ──────────────────────────────────────────────────── #}
|
||||
<div class="card">
|
||||
<h3 class="section-title" style="margin-bottom:0.75rem;">Lifecycle</h3>
|
||||
{# ── Lifecycle, newest first ─────────────────────────────────────────────── #}
|
||||
<section class="card-flush" aria-labelledby="ctr-lifecycle">
|
||||
<h2 id="ctr-lifecycle" class="table-caption">Lifecycle <span class="tone-muted">newest first</span></h2>
|
||||
{% if events %}
|
||||
<div style="display:grid;gap:0.5rem;">
|
||||
{% for e in events %}
|
||||
<div style="display:flex;align-items:baseline;gap:0.6rem;font-size:0.85rem;">
|
||||
<span style="color:{{ e.colour }};width:1rem;text-align:center;flex-shrink:0;">{{ e.glyph }}</span>
|
||||
<span style="font-weight:500;width:6.5rem;flex-shrink:0;">{{ e.event }}</span>
|
||||
<span style="color:var(--text-muted);flex:1;min-width:0;">{{ e.detail or "" }}</span>
|
||||
<span style="color:var(--text-dim);font-size:0.78rem;white-space:nowrap;" title="{{ e.at }}">{{ e.at.strftime("%Y-%m-%d %H:%M") }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead><tr><th>Event</th><th>Detail</th><th class="num">When</th></tr></thead>
|
||||
<tbody>
|
||||
{% for e in events %}
|
||||
<tr>
|
||||
<td><span class="tone-{{ e.tone }}" aria-hidden="true">{{ e.glyph }}</span> {{ e.event|replace("_", " ") }}</td>
|
||||
<td class="tone-muted" style="overflow-wrap:anywhere;">{{ e.detail or "" }}</td>
|
||||
<td class="num">{{ e.at|ago }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;">
|
||||
No lifecycle events recorded yet. Start/stop/health changes appear here as the
|
||||
agent reports them over time.
|
||||
</div>
|
||||
<p class="tone-muted" style="margin:0;padding:1rem;">No lifecycle events recorded yet. Starts, stops and health changes appear here as the agent reports them.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
{# docker/service.html — one swarm service (#5302): its state and what it
|
||||
runs, where its replicas are and what they use, Swarm's record of its recent
|
||||
tasks, its CPU and memory over time, and the logs of every task it has run.
|
||||
{# docker/service.html — one swarm service (#5302, #5310): a header that says
|
||||
whether it is well and what it runs; its last failure only while it is in
|
||||
trouble; its CPU and memory over time; one table of its tasks, the running
|
||||
ones first with what they use; and the logs of every task it has run.
|
||||
A failure's "Logs →" lands on #logs with ?task= picked. #}
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import crumbs, status_badge, status_dot, stat_tile %}
|
||||
{% from "docker/_usage.html" import cpu, mem, cores %}
|
||||
{% from "_macros.html" import crumbs, status_badge %}
|
||||
{% from "docker/_usage.html" import cpu, mem %}
|
||||
{% block title %}{{ name }} — Docker — Steward{% endblock %}
|
||||
{% block breadcrumb %}{{ crumbs([("Docker", "/plugins/docker/"), (name, "")]) }}{% endblock %}
|
||||
{% block content %}
|
||||
@@ -22,10 +23,14 @@
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
{# ── Header: is it well, then what it is ─────────────────────────────────── #}
|
||||
{# ── Header: the verdict, then what it is (#5310). Each fact has one home:
|
||||
what it uses is in Resource history, its tasks and failures in Tasks. #}
|
||||
{% set troubled = row.crash_looping or row.state != 'healthy' %}
|
||||
{% set tone = 'ok' if row.state == 'healthy' else ('warn' if row.state == 'degraded' else 'crit') %}
|
||||
{% set image = (svc.image if svc and svc.image else row.image) %}
|
||||
{% set e = row.last_error %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div style="min-width:0;">
|
||||
<h1 class="page-title" style="overflow-wrap:anywhere;">{{ name }}</h1>
|
||||
<p class="page-subtitle">
|
||||
{% if row.crash_looping %}{{ status_badge('crit', 'Crash-looping') }}
|
||||
@@ -33,16 +38,25 @@
|
||||
{% elif row.state == 'degraded' %}{{ status_badge('warn', 'Degraded') }}
|
||||
{% else %}{{ status_badge('ok', 'Healthy') }}{% endif %}
|
||||
<span class="tone-{{ tone }}">{{ row.running }} of {{ row.desired }} running</span>
|
||||
{%- if troubled and row.trouble_since %} · <span class="tone-{{ tone }}">in trouble since {{ row.trouble_since|ago }}</span>{% endif %}
|
||||
· {{ row.mode }}
|
||||
{%- if stack %} · stack <a class="chip" href="/plugins/docker/?stack={{ stack }}">{{ stack }}</a>{% endif %}
|
||||
{%- if row.trouble_since %} · in trouble since {{ row.trouble_since|ago }}{% endif %}
|
||||
{%- if image %} · <span class="mono" style="overflow-wrap:anywhere;" title="{{ image }}">{{ image.split("@")[0] }}</span>{% endif %}
|
||||
</p>
|
||||
{% if (not troubled and e) or svc %}
|
||||
<p class="page-subtitle tone-dim" style="margin-top:0.2rem;">
|
||||
{%- if not troubled and e %}Last failed {% if e.age_s is not none %}{{ e.age_s|duration }} ago{% endif %}
|
||||
{%- if e.exit_code %} (exit {{ e.exit_code }}){% endif %}
|
||||
{%- if e.task_id %} · <a href="?task={{ e.task_id }}#logs">its logs</a>{% endif %} · {% endif -%}
|
||||
{%- if svc %}reported {{ svc.updated_at|ago }} by the manager's agent{% endif -%}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="page-actions"><a class="btn btn-sm btn-ghost" href="#logs">Logs</a></div>
|
||||
</div>
|
||||
|
||||
{% if row.last_error %}
|
||||
{% set e = row.last_error %}
|
||||
{# ── What is wrong, only while it is (#5310) ──────────────────────────────── #}
|
||||
{% if troubled and e %}
|
||||
<section class="card problem-card" aria-labelledby="svc-last-error">
|
||||
<h2 id="svc-last-error" class="section-title" style="margin-bottom:0.4rem;">Last failed task</h2>
|
||||
<p style="margin:0;">
|
||||
@@ -53,60 +67,10 @@
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<div class="stat-strip">
|
||||
{{ stat_tile("CPU", cores(row.cpu_cores) if row.cpu_cores is not none else "—", hint="across running replicas") }}
|
||||
{{ stat_tile("Memory", row.mem_bytes|bytes if row.mem_bytes is not none else "—", hint="across running replicas") }}
|
||||
{{ stat_tile("Up", (row.started_at|ago|striptags|replace(" ago", "")) if row.started_at else "—", hint="since its newest replica started") }}
|
||||
{{ stat_tile("Failed (24h)", row.failures_24h or 0, state="warn" if row.failures_24h else none, hint="tasks Swarm replaced", href="#tasks" if row.failures_24h else none) }}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<dl class="kv">
|
||||
<dt>Image</dt><dd class="mono" style="overflow-wrap:anywhere;">{{ (svc.image if svc and svc.image else row.image) or "—" }}</dd>
|
||||
<dt>Mode</dt><dd>{{ row.mode }}{% if row.mode != 'global' %}, {{ row.desired }} replica{{ "s" if row.desired != 1 }}{% endif %}</dd>
|
||||
{% if svc %}<dt>Reported</dt><dd>{{ svc.updated_at|ago }} by the manager's agent</dd>{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{# ── Replicas: where it runs and what each uses ──────────────────────────── #}
|
||||
<section class="card-flush" aria-labelledby="svc-replicas">
|
||||
<h2 id="svc-replicas" class="table-caption">Replicas <span class="tone-muted">{{ replicas|length }}</span></h2>
|
||||
{% if replicas %}
|
||||
<table class="table">
|
||||
<thead><tr>
|
||||
<th>Node</th><th>Container</th><th>Status</th>
|
||||
<th class="num" title="Docker counts CPU per core">CPU</th><th class="num">Memory</th><th class="num">Up</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for r in replicas %}
|
||||
<tr>
|
||||
{% if r.ghost %}
|
||||
<td>{{ r.host }}</td>
|
||||
<td colspan="5" class="tone-dim">{{ r.count }} running on a node with no Steward agent: no figures for {{ "it" if r.count == 1 else "them" }}</td>
|
||||
{% else %}
|
||||
<td>{% if r.host_id %}<a href="/hosts/{{ r.host_id }}">{{ r.host }}</a>{% else %}{{ r.host }}{% endif %}</td>
|
||||
<td class="cell-name"><a href="/plugins/docker/container/{{ r.host_id }}/{{ r.name }}" title="{{ r.name }}">{{ r.name }}</a></td>
|
||||
<td>
|
||||
{{ status_dot('up' if r.status == 'running' else 'down') }} <span class="tone-muted">{{ r.status }}</span>
|
||||
{%- if r.health == 'unhealthy' %} {{ status_badge('crit', 'Unhealthy') }}{% elif r.health == 'starting' %} {{ status_badge('pending', 'Starting') }}{% endif %}
|
||||
</td>
|
||||
<td class="num">{% if r.usage %}{{ cpu(r.usage) }}{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||
<td class="num">{% if r.usage %}{{ mem(r.usage) }}{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||
<td class="num">{{ r.uptime or "—" }}</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="tone-muted" style="margin:0;padding:1rem;">No replica is running.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# ── Resource history, summed across replicas ────────────────────────────── #}
|
||||
<section class="card" aria-labelledby="svc-history">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:1rem;margin-bottom:0.75rem;flex-wrap:wrap;">
|
||||
<h2 id="svc-history" class="section-title" style="margin:0;">Resource history</h2>
|
||||
<h2 id="svc-history" class="section-title" style="margin:0;">Resource history <span class="tone-dim">across running replicas</span></h2>
|
||||
{% include "_time_range.html" %}
|
||||
</div>
|
||||
<div hx-get="/plugins/docker/service/{{ name }}/history"
|
||||
@@ -117,27 +81,38 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# ── Swarm's record of its recent tasks ──────────────────────────────────── #}
|
||||
{# ── Tasks: the running ones with what they use, then Swarm's record ─────── #}
|
||||
<section id="tasks" class="card-flush" aria-labelledby="svc-tasks">
|
||||
<h2 id="svc-tasks" class="table-caption">Tasks <span class="tone-muted">Swarm's most recent, newest first</span></h2>
|
||||
<h2 id="svc-tasks" class="table-caption">Tasks
|
||||
<span class="tone-muted">running first, then Swarm's most recent</span>
|
||||
{%- if row.failures_24h %} <span class="tone-warn">· {{ row.failures_24h }} failed in 24h</span>{% endif %}</h2>
|
||||
{% if tasks %}
|
||||
<table class="table">
|
||||
<thead><tr>
|
||||
<th title="The replica slot this task filled">Slot</th><th>Task</th><th>Node</th><th>State</th>
|
||||
<th class="num">Exit</th><th>Why</th><th class="num">When</th>
|
||||
<th class="num" title="Docker counts CPU per core">CPU</th><th class="num">Memory</th>
|
||||
<th class="num">Exit</th><th>Why</th><th class="num" title="When it started, for a running task; else when it reached its state">When</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for t in tasks %}
|
||||
<tr>
|
||||
<td>{{ t.slot or "—" }}</td>
|
||||
<td class="mono" title="{{ t.task_id }}">{% if t.task_id %}<a href="?task={{ t.task_id }}#logs">{{ t.task_id[:10] }}</a>{% else %}—{% endif %}</td>
|
||||
<td>{{ t.node }}</td>
|
||||
<td class="mono" title="{{ t.container or t.task_id }}">
|
||||
{%- if t.task_id %}<a href="?task={{ t.task_id }}#logs" title="Its logs">{{ t.task_id[:10] }}</a>
|
||||
{%- if t.container and t.host_id %} <a class="tone-dim" href="/plugins/docker/container/{{ t.host_id }}/{{ t.container }}" title="The container's own page">↗</a>{% endif %}
|
||||
{%- elif t.container and t.host_id %}<a href="/plugins/docker/container/{{ t.host_id }}/{{ t.container }}">{{ t.container|truncate(24, true, "…") }}</a>
|
||||
{%- else %}<span class="tone-dim">—</span>{% endif -%}
|
||||
</td>
|
||||
<td>{% if t.host_id %}<a href="/hosts/{{ t.host_id }}">{{ t.node }}</a>{% else %}{{ t.node }}{% endif %}</td>
|
||||
<td>
|
||||
{%- if t.failed %}{{ status_badge('crit', t.state|capitalize) }}
|
||||
{%- elif t.current %}{{ status_badge('ok', 'Running') }}
|
||||
{%- else %}<span class="tone-muted">{{ t.state }}</span>{% endif %}
|
||||
{%- if t.health == 'unhealthy' %} {{ status_badge('crit', 'Unhealthy') }}{% elif t.health == 'starting' %} {{ status_badge('pending', 'Starting') }}{% endif %}
|
||||
{%- if t.desired and t.desired != t.state and not t.current %} <span class="tone-dim" title="What Swarm wants of it">→ {{ t.desired }}</span>{% endif -%}
|
||||
</td>
|
||||
<td class="num">{% if t.usage %}{{ cpu(t.usage) }}{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||
<td class="num">{% if t.usage %}{{ mem(t.usage) }}{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||
<td class="num">{% if t.exit_code %}<span class="tone-{{ 'crit' if t.failed else 'dim' }}">{{ t.exit_code }}</span>{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||
<td style="overflow-wrap:anywhere;">{{ t.why or "" }}</td>
|
||||
<td class="num">{% if t.at %}{{ t.at|ago }}{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||
@@ -146,7 +121,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="tone-muted" style="margin:0;padding:1rem;">No task record yet: the manager's agent reports them from version 1.8.0.</p>
|
||||
<p class="tone-muted" style="margin:0;padding:1rem;">No task is running, and Swarm's record of its tasks has not been reported yet (the manager's agent reports it from version 1.8.0).</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user