mirror of
https://framagit.org/framasoft/framaspace/argos.git
synced 2025-04-28 09:52:38 +02:00
🗃💄🎨 — 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:
parent
cb81b80b54
commit
f2cec6a8ae
18 changed files with 211 additions and 2771 deletions
2
Makefile
2
Makefile
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
# Don’t create an alert if the severity has not changed
|
||||
if last_severity != severity:
|
||||
|
|
|
@ -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")
|
||||
|
|
BIN
argos/server/static/logo-64.png
Normal file
BIN
argos/server/static/logo-64.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.5 KiB |
BIN
argos/server/static/logo.png
Normal file
BIN
argos/server/static/logo.png
Normal file
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
5
argos/server/static/pico.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
argos/server/static/pico.min.css.map
Normal file
1
argos/server/static/pico.min.css.map
Normal file
File diff suppressed because one or more lines are too long
|
@ -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 {
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 %}
|
|
@ -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>
|
||||
|
|
79
argos/server/templates/domains.html
Normal file
79
argos/server/templates/domains.html
Normal 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 %}
|
|
@ -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">
|
||||
|
|
Loading…
Reference in a new issue