🗃💄🎨 — Lot of changes: DB, UI and some code improvements

- 🗃 Store last status and submitted_at in Task. Reduce queries number
- 🗃 Add an Unknown status
- 💄 Add a logo
- 💄 Add a navbar
- 💄 Add a filter on domains page
- 🎨 Use url_for in every templates’ href
This commit is contained in:
Luc Didry 2023-11-28 16:52:20 +01:00
parent cb81b80b54
commit f2cec6a8ae
No known key found for this signature in database
GPG key ID: EA868E12D0257E3C
18 changed files with 211 additions and 2771 deletions

View file

@ -21,7 +21,7 @@ cog: ## Run cog, to integrate the CLI options to the docs.
tests: venv ## Run the tests
venv/bin/pytest
djlint: venv ## Format the templates
venv/bin/djlint --ignore=H030,H031 --lint argos/server/templates/*html
venv/bin/djlint --ignore=H030,H031,H006 --lint argos/server/templates/*html
pylint: venv ## Runs pylint on the code
venv/bin/pylint argos
lint: djlint pylint

View file

@ -24,6 +24,7 @@ class Severity:
OK = "ok"
WARNING = "warning"
CRITICAL = "critical"
UNKNOWN = "unknown"
# XXX We could name this Result, but is it could overlap with schemas.Result.
@ -115,13 +116,15 @@ class BaseCheck:
- All FAILUREs should be reported as CRITICAL
- All SUCCESS should be reported as OK
- All ERRORS should be reported as CRITICAL.
- All ERRORS should be reported as UNKNOWN.
This behaviour can be changed in each check, by defining the `finalize` method.
XXX Allow this to be tweaked by the config.
"""
if result.status in (Status.SUCCESS, Status.ERROR):
if result.status == Status.SUCCESS:
return result.status, Severity.OK
if result.status == Status.ERROR:
return result.status, Severity.UNKNOWN
if result.status == Status.FAILURE:
return result.status, Severity.CRITICAL
if result.status == Status.ON_CHECK:

View file

@ -18,7 +18,7 @@ from typing_extensions import Annotated
from argos.schemas.utils import string_to_duration
Severity = Literal["warning", "error", "critical"]
Severity = Literal["warning", "error", "critical", "unknown"]
def parse_threshold(value):

View file

@ -41,6 +41,12 @@ class Task(Base):
completed_at: Mapped[datetime] = mapped_column(nullable=True)
next_run: Mapped[datetime] = mapped_column(nullable=True)
severity: Mapped[Literal["ok", "warning", "critical", "unknown"]] = mapped_column(
Enum("ok", "warning", "critical", "unknown", name="severity"),
insert_default="unknown"
)
submitted_at: Mapped[datetime] = mapped_column(nullable=True)
results: Mapped[List["Result"]] = relationship(back_populates="task")
def __str__(self):
@ -50,8 +56,10 @@ class Task(Base):
"""Returns a check instance for this specific task"""
return get_registered_check(self.check)
def set_times_and_deselect(self):
"""Removes the lock on task and set the time for the next run"""
def set_times_severity_and_deselect(self, severity, submitted_at):
"""Removes the lock on task, set its severity and set the time for the next run"""
self.severity = severity
self.submitted_at = submitted_at
self.selected_by = None
self.selected_at = None
@ -73,13 +81,6 @@ class Task(Base):
return None
return self.last_result.status
@property
def severity(self):
"""Get severity of the task"""
if not self.last_result:
return None
return self.last_result.severity
class Result(Base):
"""There is multiple results per tasks.
@ -100,8 +101,8 @@ class Result(Base):
status: Mapped[Literal["success", "failure", "error", "on-check"]] = mapped_column(
Enum("success", "failure", "error", "on-check", name="status")
)
severity: Mapped[Literal["ok", "warning", "critical"]] = mapped_column(
Enum("ok", "warning", "critical", name="severity")
severity: Mapped[Literal["ok", "warning", "critical", "unknown"]] = mapped_column(
Enum("ok", "warning", "critical", "unknown", name="severity")
)
context: Mapped[dict] = mapped_column()

View file

@ -110,25 +110,16 @@ async def update_from_config(db: Session, config: schemas.Config):
async def get_severity_counts(db: Session) -> dict:
"""Get the severities (ok, warning, critical…) and their count"""
# Get the last result of each task
subquery = (
db.query(Result.task_id, func.max(Result.id).label("max_result_id")) # pylint: disable-msg=not-callable
.group_by(Result.task_id)
.subquery()
)
# Join this back to get full result rows, and group by status
query = (
db.query(Result.severity, func.count(Result.id).label("count")) # pylint: disable-msg=not-callable
.join(subquery, Result.id == subquery.columns.max_result_id)
.group_by(Result.severity)
db.query(Task.severity, func.count(Task.id).label("count")) # pylint: disable-msg=not-callable
.group_by(Task.severity)
)
# Execute the query and fetch the results
task_counts_by_severity = query.all()
counts_dict = dict(task_counts_by_severity)
for key in ("ok", "warning", "critical"):
for key in ("ok", "warning", "critical", "unknown"):
counts_dict.setdefault(key, 0)
return counts_dict

View file

@ -55,7 +55,7 @@ async def create_results(
check = task.get_check()
status, severity = await check.finalize(config, result, **result.context)
result.set_status(status, severity)
task.set_times_and_deselect()
task.set_times_severity_and_deselect(severity, result.submitted_at)
# Dont create an alert if the severity has not changed
if last_severity != severity:

View file

@ -25,7 +25,7 @@ SEVERITY_LEVELS = {
@route.get("/")
async def get_severity_counts(
async def get_severity_counts_view(
request: Request,
db: Session = Depends(get_db),
refresh: bool = False,
@ -48,26 +48,21 @@ async def get_severity_counts(
)
@route.get("/details")
async def read_tasks(request: Request, db: Session = Depends(get_db)):
@route.get("/domains")
async def get_domains_view(request: Request, db: Session = Depends(get_db)):
"""Show all tasks and their current state"""
tasks = db.query(Task).all()
results = (
db.query(Task, Result)
.join(Result)
.distinct(Task.id)
.order_by(Task.id, desc(Result.submitted_at))
.all()
)
domains_severities = defaultdict(list)
domains_last_checks = defaultdict(list)
for task, result in results:
severity = result.severity or "to-process"
for task in tasks:
domain = urlparse(task.url).netloc
domains_severities[domain].append(severity)
domains_last_checks[domain].append(result.submitted_at)
domains_severities[domain].append(task.severity)
if task.submitted_at is not None:
domains_last_checks[domain] = task.submitted_at
else:
domains_last_checks[domain] = 'Waiting to be checked'
def _max_severity(severities):
return max(severities, key=SEVERITY_LEVELS.get)
@ -84,17 +79,16 @@ async def read_tasks(request: Request, db: Session = Depends(get_db)):
return 0
domains = [(key, _max_severity(value)) for key, value in domains_severities.items()]
last_checks = {key: max(value) for key, value in domains_last_checks.items()}
domains.sort(key=cmp_to_key(_cmp_domains))
agents = db.query(Result.agent_id).distinct().all()
return templates.TemplateResponse(
"details.html",
"domains.html",
{
"request": request,
"domains": domains,
"last_checks": last_checks,
"last_checks": domains_last_checks,
"total_task_count": len(tasks),
"agents": agents,
},
@ -102,18 +96,18 @@ async def read_tasks(request: Request, db: Session = Depends(get_db)):
@route.get("/domain/{domain}")
async def get_domain_tasks(
async def get_domain_tasks_view(
request: Request, domain: str, db: Session = Depends(get_db)
):
"""Show all tasks attached to a domain"""
tasks = db.query(Task).filter(Task.domain.contains(domain)).all() # type: ignore[attr-defined]
tasks = db.query(Task).filter(Task.domain.contains(f'//{domain}')).all()
return templates.TemplateResponse(
"domain.html", {"request": request, "domain": domain, "tasks": tasks}
)
@route.get("/result/{result_id}")
async def get_result(request: Request, result_id: int, db: Session = Depends(get_db)):
async def get_result_view(request: Request, result_id: int, db: Session = Depends(get_db)):
"""Show the details of a result"""
result = db.query(Result).get(result_id)
return templates.TemplateResponse(
@ -122,7 +116,7 @@ async def get_result(request: Request, result_id: int, db: Session = Depends(get
@route.get("/task/{task_id}/results")
async def get_task_results(
async def get_task_results_view(
request: Request,
task_id: int,
db: Session = Depends(get_db),
@ -149,7 +143,7 @@ async def get_task_results(
@route.get("/agents")
async def get_agents(request: Request, db: Session = Depends(get_db)):
async def get_agents_view(request: Request, db: Session = Depends(get_db)):
"""Show argos agents and the last time the server saw them"""
t1 = aliased(Result, name="t1")
t2 = aliased(Result, name="t2")

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

File diff suppressed because it is too large Load diff

5
argos/server/static/pico.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,9 +1,21 @@
@import url("pico.css");
@import url("pico.min.css");
code {
white-space: pre-wrap;
}
body > header,
body > main {
padding: 0 !important;
}
#title {
margin-bottom: 0;
}
h2 {
margin-bottom: calc(var(--typography-spacing-vertical) * 0.5);
}
.grid-index {
font-size: 2rem;
}
@ -14,6 +26,17 @@ code {
.grid-index article {
margin-top: 0;
margin-bottom: 1rem;
padding-bottom: calc(var(--block-spacing-vertical) * 0.7);
}
.grid-index article > header {
margin-bottom: calc(var(--block-spacing-vertical) * 0.7);
}
label[for="select-status"] {
display: inline-block;
}
#select-status {
width: initial;
}
.inline-label {

View file

@ -3,20 +3,55 @@
<head>
<meta charset="utf-8">
<title>Argos</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=Edge">
<link rel="shortcut icon"
href="{{ url_for('static', path='/logo.png') }}">
<meta name="viewport"
content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible"
content="IE=Edge">
{% if refresh %}
<meta http-equiv="refresh"
content="{{ delay }}">
{% endif %}
<link href="{{ url_for('static', path='/styles.css') }}"
rel="stylesheet">
<link rel="stylesheet"
href="{{ url_for('static', path='/styles.css') }}">
</head>
<body>
<header class="container">
<nav>
<ul>
<li>
<img src="{{ url_for('static', path='/logo-64.png') }}"
alt="">
</li>
<li>
<h1 id="title">Argos monitoring</h1>
</li>
</ul>
<ul>
<li>
<a href="{{ url_for('get_severity_counts_view') }}"
class="outline {{ 'contrast' if request.url == url_for('get_severity_counts_view') }}"
role="button">
Dashboard
</a>
<a href="{{ url_for('get_domains_view') }}"
class="outline {{ 'contrast' if request.url == url_for('get_domains_view') }}"
role="button">
Domains
</a>
<a href="{{ url_for('get_agents_view') }}"
class="outline {{ 'contrast' if request.url == url_for('get_agents_view') }}"
role="button">
Agents
</a>
</li>
</ul>
</nav>
</header>
<main class="container">
<div id="header">
{% block title %}
<a href="/"><h1 id="title">Argos monitoring</h1></a>
{% endblock %}
</div>
<div id="content">

View file

@ -1,33 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div id="domains" class="frame">
<p>
{{ domains | length}} domains,
{{ total_task_count }} tasks,
<a href="/agents">{{ agents | length }} agent{% if agents | length > 1 %}s{% endif %}</a>
</p>
<table id="domains-list" role="grid">
<thead>
<tr>
<th>Domain</th>
<th class="today">Current status</th>
<th>Last check</th>
</tr>
</thead>
<tbody id="domains-body">
{% for (domain, status) in domains %}
<tr>
<td>
<a href="/domain/{{ domain }}">{{ domain }}</a>
</td>
<td class="status highlight">
{% if status == "ok" %}✅ OK {% elif statuts == "warning"%}⚠ Warning{% elif status == "critical"%}❌ Critical{% elif status == "to-process" %}⏱︎ Waiting for the jobs{% endif %}
</td>
<td>{{ last_checks.get(domain) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View file

@ -20,9 +20,24 @@
<td>{{ task.check }}</td>
<td>{{ task.expected }}</td>
<td class="status highlight">
<a data-tooltip="Completed at {{ task.completed_at }}" href="/result/{{ task.last_result.id }}">{% if task.status == "success" %}✅ Success {% elif task.status == "error"%}⚠ Error{% elif task.status == "failure"%}❌ Failure{% endif %}</a>
{% if task.status %}
<a data-tooltip="Completed at {{ task.completed_at }}"
href="{{ url_for('get_result_view', result_id=task.last_result.id) }}">
{% if task.status == "success" %}
✅ Success
{% elif task.status == "error"%}
⚠ Error
{% elif task.status == "failure"%}
❌ Failure
{% elif task.status == "unknown" %}
❔ Unknown
{% endif %}
</a>
{% else %}
Waiting to be checked
{% endif %}
</td>
<td><a href="/task/{{task.id}}/results">view all</a></td>
<td><a href="{{ url_for('get_task_results_view', task_id=task.id) }}">view all</a></td>
</tr>
{% endfor %}
</tbody>

View file

@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}<h2>Tasks list</h2>{% endblock %}
{% block content %}
<div id="domains" class="frame">
<nav>
<ul>
<li>
{{ domains | length}} domains,
{{ total_task_count }} tasks,
<a href="{{ url_for('get_agents_view') }}">
{{ agents | length }} agent{{ 's' if agents | length > 1 }}
</a>
</li>
</ul>
<ul>
<li>
<label for="select-status">Show domains with status:</label>
<select id="select-status">
<option value="all">All</option>
<option value="ok">✅ OK</option>
<option value="warning">⚠️ Warning</option>
<option value="critical">❌ Critical</option>
<option value="unknown">❔ Unknown</option>
</select>
</li>
</ul>
</nav>
<table id="domains-list" role="grid">
<thead>
<tr>
<th>Domain</th>
<th class="today">Current status</th>
<th>Last check</th>
</tr>
</thead>
<tbody id="domains-body">
{% for (domain, status) in domains %}
<tr data-status={{ status }}>
<td>
<a href="{{ url_for('get_domain_tasks_view', domain=domain) }}">
{{ domain }}
</a>
</td>
<td class="status highlight">
{% if status == "ok" %}
✅ OK
{% elif status == "warning"%}
⚠️ Warning
{% elif status == "critical"%}
❌ Critical
{% elif status == "unknown" %}
❔ Unknown
{% endif %}
</td>
<td>{{ last_checks.get(domain) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<script>
document.getElementById('select-status').addEventListener('change', (e) => {
if (e.currentTarget.value === 'all') {
document.querySelectorAll('[data-status]').forEach((item) => {
item.style.display = null;
})
} else {
document.querySelectorAll('[data-status]').forEach((item) => {
if (item.dataset.status === e.currentTarget.value) {
item.style.display = null;
} else {
item.style.display = 'none';
}
})
}
});
</script>
{% endblock %}

View file

@ -2,6 +2,7 @@
{% block title %}<h2>Dashboard</h2>{% endblock %}
{% block content %}
<div id="domains" class="frame">
<<<<<<< HEAD
<nav>
<ul>
<li>
@ -44,19 +45,19 @@
<div class="grid grid-index">
<article>
<header title="Unknown"></header>
<span id="sev-unknown">{{ counts_dict['unknown'] }}</span>
{{ counts_dict['unknown'] }}
</article>
<article>
<header title="OK"></header>
<span id="sev-ok">{{ counts_dict['ok'] }}</span>
{{ counts_dict['ok'] }}
</article>
<article>
<header title="Warning">⚠️</header>
<span id="sev-warning">{{ counts_dict['warning'] }}</span>
{{ counts_dict['warning'] }}
</article>
<article>
<header title="Critical"></header>
<span id="sev-critical">{{ counts_dict['critical'] }}</span>
{{ counts_dict['critical'] }}
</article>
</div>
<p class="text-center">