Merge branch 'simplify-schema' into 'main'

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

Closes #27

See merge request framasoft/framaspace/argos!17
This commit is contained in:
Luc Didry 2024-03-21 14:49:02 +00:00
commit 017738ffd7
26 changed files with 430 additions and 2806 deletions

View file

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

View file

@ -0,0 +1,37 @@
"""Add severity to Task and add severity level UNKNOWN
Revision ID: e99bc35702c9
Revises: 7d480e6f1112
Create Date: 2024-02-28 14:14:22.519918
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'e99bc35702c9'
down_revision: Union[str, None] = '7d480e6f1112'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("ALTER TYPE severity ADD VALUE 'unknown'")
op.add_column('tasks',
sa.Column('severity',
sa.Enum(
'ok',
'warning',
'critical',
'unknown',
name='severity'),
nullable=False))
op.add_column('tasks', sa.Column('last_severity_update', sa.DateTime(), nullable=True))
def downgrade() -> None:
op.drop_column('tasks', 'last_severity_update')
op.drop_column('tasks', 'severity')

View file

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

View file

@ -71,7 +71,7 @@ def agent(server_url, auth, max_tasks, wait_time, log_level):
from argos.logging import logger from argos.logging import logger
logger.setLevel(log_level) logger.setLevel(log_level)
agent_ = ArgosAgent(server_url, auth, max_tasks, wait_time) agent_ = ArgosAgent(server, auth, max_tasks, wait_time)
asyncio.run(agent_.run()) asyncio.run(agent_.run())

View file

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

View file

@ -41,7 +41,7 @@ Status: {severity}
Time: {result.submitted_at} Time: {result.submitted_at}
Previous status: {old_severity} Previous status: {old_severity}
See results of task on {request.url_for('get_task_results', task_id=task.id)} See results of task on {request.url_for('get_task_results_view', task_id=task.id)}
""" """
mail = f"""\ mail = f"""\
@ -103,7 +103,7 @@ Status: {severity}
Time: {result.submitted_at} Time: {result.submitted_at}
Previous status: {old_severity} Previous status: {old_severity}
See results of task on {request.url_for('get_task_results', task_id=task.id)} See results of task on {request.url_for('get_task_results_view', task_id=task.id)}
""" """
payload = {'title': subject, payload = {'title': subject,

View file

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

View file

@ -110,29 +110,28 @@ async def update_from_config(db: Session, config: schemas.Config):
async def get_severity_counts(db: Session) -> dict: async def get_severity_counts(db: Session) -> dict:
"""Get the severities (ok, warning, critical…) and their count""" """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 = ( query = (
db.query(Result.severity, func.count(Result.id).label("count")) # pylint: disable-msg=not-callable db.query(Task.severity, func.count(Task.id).label("count")) # pylint: disable-msg=not-callable
.join(subquery, Result.id == subquery.columns.max_result_id) .group_by(Task.severity)
.group_by(Result.severity)
) )
# Execute the query and fetch the results # Execute the query and fetch the results
task_counts_by_severity = query.all() task_counts_by_severity = query.all()
counts_dict = dict(task_counts_by_severity) 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) counts_dict.setdefault(key, 0)
return counts_dict return counts_dict
async def reschedule_all(db: Session):
"""Reschedule checks of all non OK tasks ASAP"""
db.query(Task) \
.filter(Task.severity.in_(['warning', 'critical', 'unknown'])) \
.update({Task.next_run: datetime.now() - timedelta(days=1)})
db.commit()
async def remove_old_results(db: Session, max_results: int): async def remove_old_results(db: Session, max_results: int):
tasks = db.query(Task).all() tasks = db.query(Task).all()
deleted = 0 deleted = 0

View file

@ -55,7 +55,7 @@ async def create_results(
check = task.get_check() check = task.get_check()
status, severity = await check.finalize(config, result, **result.context) status, severity = await check.finalize(config, result, **result.context)
result.set_status(status, severity) 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 # Dont create an alert if the severity has not changed
if last_severity != severity: if last_severity != severity:
@ -67,7 +67,40 @@ async def create_results(
return {"result_ids": [r.id for r in db_results]} return {"result_ids": [r.id for r in db_results]}
@route.get("/stats") @route.post("/reschedule/all",
responses={
200: {
"content": {
"application/json": {
"example": {
"msg": "Non OK tasks reschuled"
}
}
}
}
}
)
async def reschedule_all(request: Request, db: Session = Depends(get_db)):
"""Reschedule checks of all non OK tasks ASAP"""
await queries.reschedule_all(db)
return {"msg": "Non OK tasks reschuled"}
@route.get("/stats",
responses={
200: {
"content": {
"application/json": {
"example": {
"upcoming_tasks_count":0,
"results_count":1993085,
"selected_tasks_count":1845
}
}
}
}
}
)
async def get_stats(db: Session = Depends(get_db)): async def get_stats(db: Session = Depends(get_db)):
"""Get tasks statistics""" """Get tasks statistics"""
return { return {
@ -77,7 +110,19 @@ async def get_stats(db: Session = Depends(get_db)):
} }
@route.get("/severities") @route.get("/severities",
responses={
200: {
"content": {
"application/json": {
"example": {
"ok":1541,"warning":0,"critical":0,"unknown":0
}
}
}
}
}
)
async def get_severity_counts(db: Session = Depends(get_db)): async def get_severity_counts(db: Session = Depends(get_db)):
"""Returns the number of results per severity""" """Returns the number of results per severity"""
return await queries.get_severity_counts(db) return await queries.get_severity_counts(db)

View file

@ -5,8 +5,8 @@ from urllib.parse import urlparse
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy import desc from sqlalchemy import func
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session
from argos.schemas import Config from argos.schemas import Config
from argos.server import queries from argos.server import queries
@ -20,12 +20,12 @@ SEVERITY_LEVELS = {
"ok": 1, "ok": 1,
"warning": 2, "warning": 2,
"critical": 3, "critical": 3,
"to-process": 4 "unknown": 4
} }
@route.get("/") @route.get("/")
async def get_severity_counts( async def get_severity_counts_view(
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
refresh: bool = False, refresh: bool = False,
@ -48,26 +48,21 @@ async def get_severity_counts(
) )
@route.get("/details") @route.get("/domains")
async def read_tasks(request: Request, db: Session = Depends(get_db)): async def get_domains_view(request: Request, db: Session = Depends(get_db)):
"""Show all tasks and their current state""" """Show all tasks and their current state"""
tasks = db.query(Task).all() 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_severities = defaultdict(list)
domains_last_checks = 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 domain = urlparse(task.url).netloc
domains_severities[domain].append(severity) domains_severities[domain].append(task.severity)
domains_last_checks[domain].append(result.submitted_at) if task.last_severity_update is not None:
domains_last_checks[domain] = task.last_severity_update
else:
domains_last_checks[domain] = 'Waiting to be checked'
def _max_severity(severities): def _max_severity(severities):
return max(severities, key=SEVERITY_LEVELS.get) return max(severities, key=SEVERITY_LEVELS.get)
@ -84,17 +79,16 @@ async def read_tasks(request: Request, db: Session = Depends(get_db)):
return 0 return 0
domains = [(key, _max_severity(value)) for key, value in domains_severities.items()] 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)) domains.sort(key=cmp_to_key(_cmp_domains))
agents = db.query(Result.agent_id).distinct().all() agents = db.query(Result.agent_id).distinct().all()
return templates.TemplateResponse( return templates.TemplateResponse(
"details.html", "domains.html",
{ {
"request": request, "request": request,
"domains": domains, "domains": domains,
"last_checks": last_checks, "last_checks": domains_last_checks,
"total_task_count": len(tasks), "total_task_count": len(tasks),
"agents": agents, "agents": agents,
}, },
@ -102,18 +96,18 @@ async def read_tasks(request: Request, db: Session = Depends(get_db)):
@route.get("/domain/{domain}") @route.get("/domain/{domain}")
async def get_domain_tasks( async def get_domain_tasks_view(
request: Request, domain: str, db: Session = Depends(get_db) request: Request, domain: str, db: Session = Depends(get_db)
): ):
"""Show all tasks attached to a domain""" """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( return templates.TemplateResponse(
"domain.html", {"request": request, "domain": domain, "tasks": tasks} "domain.html", {"request": request, "domain": domain, "tasks": tasks}
) )
@route.get("/result/{result_id}") @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""" """Show the details of a result"""
result = db.query(Result).get(result_id) result = db.query(Result).get(result_id)
return templates.TemplateResponse( 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") @route.get("/task/{task_id}/results")
async def get_task_results( async def get_task_results_view(
request: Request, request: Request,
task_id: int, task_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
@ -149,17 +143,11 @@ async def get_task_results(
@route.get("/agents") @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""" """Show argos agents and the last time the server saw them"""
t1 = aliased(Result, name="t1")
t2 = aliased(Result, name="t2")
last_seen = ( last_seen = (
db.query(t1) db.query(Result.agent_id, func.max(Result.submitted_at).label('submitted_at'))
.outerjoin( .group_by(Result.agent_id)
t2, (t1.agent_id == t2.agent_id) & (t1.submitted_at < t2.submitted_at)
)
.filter(t2.agent_id.is_(None))
.all() .all()
) )

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 { code {
white-space: pre-wrap; 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 { .grid-index {
font-size: 2rem; font-size: 2rem;
} }
@ -14,6 +26,17 @@ code {
.grid-index article { .grid-index article {
margin-top: 0; margin-top: 0;
margin-bottom: 1rem; 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 { .inline-label {

View file

@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}<h2>Agents</h2>{% endblock %} {% block title %}<h2>Agents</h2>{% endblock title %}
{% block content %} {% block content %}
<table role="grid"> <table role="grid">
<thead> <thead>
@ -17,4 +17,4 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% endblock %} {% endblock content %}

View file

@ -3,25 +3,69 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>Argos</title> <title>Argos</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="shortcut icon"
<meta http-equiv="X-UA-Compatible" content="IE=Edge"> 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 %} {% if refresh %}
<meta http-equiv="refresh" <meta http-equiv="refresh"
content="{{ delay }}"> content="{{ delay }}">
{% endif %} {% endif %}
<link href="{{ url_for('static', path='/styles.css') }}" <link rel="stylesheet"
rel="stylesheet"> href="{{ url_for('static', path='/styles.css') }}">
</head> </head>
<body> <body>
<header class="container">
<nav>
<ul>
<li>
<img src="{{ url_for('static', path='/logo-64.png') }}"
width="64"
height="64"
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>
<a href="#"
id="reschedule-all"
class="outline"
title="Reschedule non-ok checks as soon as possible">
🕐
</a>
</li>
</ul>
</nav>
</header>
<main class="container"> <main class="container">
<dialog id="msg"></dialog>
<div id="header"> <div id="header">
{% block title %} {% block title %}
<a href="/"><h1 id="title">Argos monitoring</h1></a> {% endblock title %}
{% endblock %}
</div> </div>
<div id="content"> <div id="content">
{% block content %} {% block content %}
{% endblock %} {% endblock content %}
</div> </div>
</main> </main>
<footer class="text-center"> <footer class="text-center">
@ -34,5 +78,21 @@
or or
<a href="{{ url_for('get_severity_counts_view') }}redoc">Redoc</a> <a href="{{ url_for('get_severity_counts_view') }}redoc">Redoc</a>
</footer> </footer>
<script>
async function rescheduleAll() {
const response = await fetch('{{ url_for("reschedule_all") }}', {method: 'POST'});
const json = await response.json();
const dialog = document.getElementById('msg');
dialog.innerText = json.msg;
dialog.setAttribute('open', '');
setTimeout(() => {
dialog.removeAttribute('open');
}, 1500);
}
document.getElementById('reschedule-all').addEventListener('click', event => {
event.preventDefault();
rescheduleAll();
});
</script>
</body> </body>
</html> </html>

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

@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}<h2>{{ domain }}</h2>{% endblock %} {% block title %}<h2>{{ domain }}</h2>{% endblock title %}
{% block content %} {% block content %}
<div id="domains" class="frame"> <div id="domains" class="frame">
<table id="domains-list" role="grid"> <table id="domains-list" role="grid">
@ -20,12 +20,27 @@
<td>{{ task.check }}</td> <td>{{ task.check }}</td>
<td>{{ task.expected }}</td> <td>{{ task.expected }}</td>
<td class="status highlight"> <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>
<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> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
{% endblock %} {% endblock content %}

View file

@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}<h2>Tasks list</h2>{% endblock title %}
{% 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 content %}

View file

@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}<h2>Dashboard</h2>{% endblock %} {% block title %}<h2>Dashboard</h2>{% endblock title %}
{% block content %} {% block content %}
<div id="domains" class="frame"> <div id="domains" class="frame">
<nav> <nav>
@ -44,19 +44,19 @@
<div class="grid grid-index"> <div class="grid grid-index">
<article> <article>
<header title="Unknown"></header> <header title="Unknown"></header>
<span id="sev-unknown">{{ counts_dict['unknown'] }}</span> {{ counts_dict['unknown'] }}
</article> </article>
<article> <article>
<header title="OK"></header> <header title="OK"></header>
<span id="sev-ok">{{ counts_dict['ok'] }}</span> {{ counts_dict['ok'] }}
</article> </article>
<article> <article>
<header title="Warning">⚠️</header> <header title="Warning">⚠️</header>
<span id="sev-warning">{{ counts_dict['warning'] }}</span> {{ counts_dict['warning'] }}
</article> </article>
<article> <article>
<header title="Critical"></header> <header title="Critical"></header>
<span id="sev-critical">{{ counts_dict['critical'] }}</span> {{ counts_dict['critical'] }}
</article> </article>
</div> </div>
<p class="text-center"> <p class="text-center">
@ -68,4 +68,4 @@
</p> </p>
</div> </div>
</div> </div>
{% endblock %} {% endblock content %}

View file

@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}<h2>{{ result }}</h2>{% endblock %} {% block title %}<h2>{{ result }}</h2>{% endblock title %}
{% block content %} {% block content %}
<dl> <dl>
<dt>Task</dt> <dt>Task</dt>
@ -13,4 +13,4 @@
<dt>Context</dt> <dt>Context</dt>
<dd>{{ result.context }}</dd> <dd>{{ result.context }}</dd>
</dl> </dl>
{% endblock %} {% endblock content %}

View file

@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}<h2>{{ task }}</h2>{% endblock %} {% block title %}<h2>{{ task }}</h2>{% endblock title %}
{% block content %} {% block content %}
<code>{{ description }}</code> <code>{{ description }}</code>
<table role="grid"> <table role="grid">
@ -23,4 +23,4 @@
</tbody> </tbody>
</table> </table>
{% endblock %} {% endblock content %}

View file

@ -2,13 +2,14 @@
## Python packages ## Python packages
- [Click](https://click.palletsprojects.com/) for the command-line interface ; - [Click](https://click.palletsprojects.com/) for the command-line interface;
- [FastAPI](https://fastapi.tiangolo.com/) is the framework that allows us to expose the HTTP API ; - [FastAPI](https://fastapi.tiangolo.com/) is the framework that allows us to expose the HTTP API;
- [HTTPX](https://www.python-httpx.org/) is used to issue asynchronous requests in the agents ; - [HTTPX](https://www.python-httpx.org/) is used to issue asynchronous requests in the agents;
- [Jinja](https://jinja.palletsprojects.com/) is handling the templating ; - [Jinja](https://jinja.palletsprojects.com/) is handling the templating;
- [Pydantic](https://pydantic.dev/) is useful to ensure the data matches our expectactions ; - [Pydantic](https://pydantic.dev/) is useful to ensure the data matches our expectactions;
- [SQLAlchemy](https://www.sqlalchemy.org/) is the ORM we use, to connect to our database and issue queries ; - [SQLAlchemy](https://www.sqlalchemy.org/) is the ORM we use, to connect to our database and issue queries;
- [Tenacity](https://github.com/jd/tenacity) a small utility to retry a function in case an error occured ; - [Alembic](https://alembic.sqlalchemy.org) is used for DB migrations;
- [Tenacity](https://github.com/jd/tenacity) a small utility to retry a function in case an error occured;
- [Uvicorn](https://www.uvicorn.org/) is the tool used to run our server. - [Uvicorn](https://www.uvicorn.org/) is the tool used to run our server.
## CSS framework ## CSS framework

View file

@ -123,6 +123,21 @@ async def test_update_from_config_db_updates_existing_tasks(db, empty_config, ta
assert db.query(Task).count() == 1 assert db.query(Task).count() == 1
@pytest.mark.asyncio
async def test_reschedule_all(db, ten_tasks, ten_warning_tasks, ten_critical_tasks, ten_ok_tasks):
assert db.query(Task).count() == 40
assert db.query(Task).filter(Task.severity == "unknown").count() == 10
assert db.query(Task).filter(Task.severity == "warning").count() == 10
assert db.query(Task).filter(Task.severity == "critical").count() == 10
assert db.query(Task).filter(Task.severity == "ok").count() == 10
one_hour_ago = datetime.now() - timedelta(hours=1)
assert db.query(Task).filter(Task.next_run <= one_hour_ago).count() == 0
await queries.reschedule_all(db)
assert db.query(Task).filter(Task.next_run <= one_hour_ago).count() == 30
@pytest.fixture @pytest.fixture
def task(db): def task(db):
task = Task( task = Task(
@ -215,3 +230,63 @@ def ten_tasks(db):
tasks.append(task) tasks.append(task)
db.commit() db.commit()
return tasks return tasks
@pytest.fixture
def ten_warning_tasks(db):
now = datetime.now()
tasks = []
for i in range(10):
task = Task(
url="https://www.example.com",
domain="example.com",
check="body-contains",
expected="foo",
frequency=1,
next_run=now,
severity="warning"
)
db.add(task)
tasks.append(task)
db.commit()
return tasks
@pytest.fixture
def ten_critical_tasks(db):
now = datetime.now()
tasks = []
for i in range(10):
task = Task(
url="https://www.example.com",
domain="example.com",
check="body-contains",
expected="foo",
frequency=1,
next_run=now,
severity="critical"
)
db.add(task)
tasks.append(task)
db.commit()
return tasks
@pytest.fixture
def ten_ok_tasks(db):
now = datetime.now()
tasks = []
for i in range(10):
task = Task(
url="https://www.example.com",
domain="example.com",
check="body-contains",
expected="foo",
frequency=1,
next_run=now,
severity="ok"
)
db.add(task)
tasks.append(task)
db.commit()
return tasks