Started working on a simple web interface.

- The web interface is exposed at /, and the api
  at /api.
- Include picocss for a minimal CSS framework
- Added some queries and models.Task properties
  to access the latest results
This commit is contained in:
Alexis Métaireau 2023-10-13 09:43:47 +02:00
parent e2d8066746
commit 83f57c6e47
20 changed files with 2872 additions and 54 deletions

View file

@ -16,7 +16,7 @@ Features :
- [x] Change the naming and use service/agent.
- [x] Packaging (and `argos agent` / `argos service` commands)
- [x] Endpoints are protected by an authentication token
- [ ] Task frequency can be defined in the configuration
- [x] Task frequency can be defined in the configuration
- [ ] Add a command to generate new authentication tokens
- [ ] Local task for database cleanup (to run periodically)
- [ ] Handles multiple alerting backends (email, sms, gotify) ;

View file

@ -3,7 +3,7 @@ from typing import List
import httpx
from argos.checks import CheckNotFound, get_registered_check
from argos.checks import get_registered_check
from argos.logging import logger
from argos.schemas import AgentResult, SerializableException, Task
@ -29,7 +29,7 @@ async def post_results(
http_client: httpx.AsyncClient, server: str, results: List[AgentResult]
):
data = [r.model_dump() for r in results]
response = await http_client.post(f"{server}/results", json=data)
response = await http_client.post(f"{server}/api/results", json=data)
if response.status_code == httpx.codes.CREATED:
logger.error(f"Successfully posted results {response.json()}")
@ -40,7 +40,7 @@ async def post_results(
async def get_and_complete_tasks(http_client, server, max_tasks):
# Fetch the list of tasks
response = await http_client.get(f"{server}/tasks")
response = await http_client.get(f"{server}/api/tasks")
if response.status_code == httpx.codes.OK:
# XXX Maybe we want to group the tests by URL ? (to issue one request per URL)

View file

@ -1,4 +1,7 @@
from argos.checks.base import (CheckNotFound, get_registered_check,
get_registered_checks)
from argos.checks.checks import (HTTPBodyContains, HTTPStatus,
SSLCertificateExpiration)
from argos.checks.base import (
BaseCheck,
CheckNotFound,
get_registered_check,
get_registered_checks,
)
from argos.checks.checks import HTTPBodyContains, HTTPStatus, SSLCertificateExpiration

View file

@ -2,7 +2,7 @@ from dataclasses import dataclass
from typing import Type
import httpx
from pydantic import BaseModel, Field
from pydantic import BaseModel
from argos.schemas import Task
@ -112,7 +112,10 @@ class BaseCheck:
elif result.status == Status.FAILURE:
return result.status, Severity.CRITICAL
elif result.status == Status.ON_CHECK:
msg = "Status is 'on-check', but the Check class didn't provide a finalize() method."
msg = (
"Status is 'on-check', but the Check class "
"didn't provide a finalize() method."
)
raise ValueError(msg)

View file

@ -42,6 +42,7 @@ class SerializableException(BaseModel):
class AgentResult(BaseModel):
task_id: int
# The checked status means that the service needs to finish the checks to determine the severity.
# The on-check status means that the service needs to finish the check
# and will then determine the severity.
status: Literal["success", "failure", "error", "on-check"]
context: dict | SerializableException

View file

@ -1,13 +1,13 @@
import sys
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from pydantic import ValidationError
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from argos.logging import logger
from argos.server import models, queries
from argos.server.api import api as api_router
from argos.server import models, queries, routes
from argos.server.settings import get_app_settings, read_yaml_config
@ -26,7 +26,9 @@ def get_application() -> FastAPI:
"shutdown",
create_stop_app_handler(app),
)
app.include_router(api_router)
app.include_router(routes.api, prefix="/api")
app.include_router(routes.views)
app.mount("/static", StaticFiles(directory="argos/server/static"), name="static")
return app

View file

@ -6,9 +6,9 @@ from sqlalchemy import (
Enum,
ForeignKey,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship
from argos.checks import get_registered_check
from argos.checks import BaseCheck, get_registered_check
from argos.schemas import WebsiteCheck
@ -45,7 +45,7 @@ class Task(Base):
def __str__(self):
return f"DB Task {self.url} - {self.check} - {self.expected}"
def get_check(self):
def get_check(self) -> BaseCheck:
"""Returns a check instance for this specific task"""
return get_registered_check(self.check)
@ -54,7 +54,18 @@ class Task(Base):
now = datetime.now()
self.completed_at = now
self.next_run = now + timedelta(hours=self.frequency)
self.next_run = now + timedelta(minutes=self.frequency)
def get_last_result(self):
return max(self.results, key=lambda r: r.id)
@property
def status(self):
return self.get_last_result().status
@property
def severity(self):
return self.get_last_result().severity
class Result(Base):

View file

@ -1,7 +1,9 @@
from collections import defaultdict
from datetime import datetime
from urllib.parse import urljoin
from sqlalchemy.orm import Session
from sqlalchemy import desc, func
from sqlalchemy.orm import Session, aliased
from argos import schemas
from argos.logging import logger
@ -14,7 +16,7 @@ async def list_tasks(db: Session, agent_id: str, limit: int = 100):
db.query(Task)
.filter(
Task.selected_by == None, # noqa: E711
((Task.next_run >= datetime.now()) | (Task.next_run == None)), # noqa: E711
((Task.next_run <= datetime.now()) | (Task.next_run == None)), # noqa: E711
)
.limit(limit)
.all()
@ -28,7 +30,7 @@ async def list_tasks(db: Session, agent_id: str, limit: int = 100):
return tasks
async def get_task(db: Session, id):
async def get_task(db: Session, id: int) -> Task:
return db.get(Task, id)
@ -92,3 +94,37 @@ async def update_from_config(db: Session, config: schemas.Config):
msg = f"Skipping db task creation for {url=}, {check_key=}, {expected=}, {frequency=}."
logger.debug(msg)
db.commit()
async def get_severity_counts(db):
# Get the last result of each task
subquery = (
db.query(Result.task_id, func.max(Result.id).label("max_result_id"))
.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"))
.join(subquery, Result.id == subquery.columns.max_result_id)
.group_by(Result.severity)
)
# Execute the query and fetch the results
task_counts_by_severity = query.all()
return task_counts_by_severity
async def get_domains_with_severities(db):
tasks = db.query(Task).group_by(Task.domain).all()
domains = defaultdict(list)
for task in tasks:
domains[task.domain].append(task.severity)
def _max_severity(severities):
severity_level = {"ok": 1, "warning": 2, "critical": 3}
return max(severities, key=severity_level.get)
return {key: _max_severity(value) for key, value in domains.items()}

View file

@ -0,0 +1,2 @@
from .api import route as api
from .views import route as views

View file

@ -1,48 +1,27 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from argos.logging import logger
from argos.schemas import AgentResult, Config, Task
from argos.server import queries
from argos.server.alerting import handle_alert
from argos.server.routes.dependencies import get_config, get_db, verify_token
api = APIRouter()
auth_scheme = HTTPBearer()
def get_db(request: Request):
db = request.app.state.SessionLocal()
try:
yield db
finally:
db.close()
def get_config(request: Request):
return request.app.state.config
async def verify_token(
request: Request, token: HTTPAuthorizationCredentials = Depends(auth_scheme)
):
if token.credentials not in request.app.state.config.service.secrets:
raise HTTPException(status_code=401, detail="Unauthorized")
return token
route = APIRouter()
# XXX Get the default limit from the config
@api.get("/tasks", response_model=list[Task], dependencies=[Depends(verify_token)])
@route.get("/tasks", response_model=list[Task], dependencies=[Depends(verify_token)])
async def read_tasks(request: Request, db: Session = Depends(get_db), limit: int = 20):
# XXX Let the agents specifify their names (and use hostnames)
tasks = await queries.list_tasks(db, agent_id=request.client.host, limit=limit)
return tasks
@api.post("/results", status_code=201, dependencies=[Depends(verify_token)])
async def create_result(
@route.post("/results", status_code=201, dependencies=[Depends(verify_token)])
async def create_results(
results: List[AgentResult],
db: Session = Depends(get_db),
config: Config = Depends(get_config),
@ -76,10 +55,20 @@ async def create_result(
return {"result_ids": [r.id for r in db_results]}
@api.get("/stats", dependencies=[Depends(verify_token)])
@route.get("/stats", dependencies=[Depends(verify_token)])
async def get_stats(db: Session = Depends(get_db)):
return {
"upcoming_tasks_count": await queries.count_tasks(db, selected=False),
"results_count": await queries.count_results(db),
"selected_tasks_count": await queries.count_tasks(db, selected=True),
}
@route.get("/severities", dependencies=[Depends(verify_token)])
async def get_severity_counts(db: Session = Depends(get_db)):
"""Returns the number of results per severity"""
counts = await queries.get_severity_counts(db)
counts_dict = dict(counts)
for key in ("ok", "warning", "critical"):
counts_dict.setdefault(key, 0)
return counts_dict

View file

@ -0,0 +1,24 @@
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
auth_scheme = HTTPBearer()
def get_db(request: Request):
db = request.app.state.SessionLocal()
try:
yield db
finally:
db.close()
def get_config(request: Request):
return request.app.state.config
async def verify_token(
request: Request, token: HTTPAuthorizationCredentials = Depends(auth_scheme)
):
if token.credentials not in request.app.state.config.service.secrets:
raise HTTPException(status_code=401, detail="Unauthorized")
return token

View file

@ -0,0 +1,19 @@
from fastapi import APIRouter, Depends, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from argos.server import queries
from argos.server.routes.dependencies import get_db
route = APIRouter()
templates = Jinja2Templates(directory="argos/server/templates")
@route.get("/")
async def read_tasks(request: Request, db: Session = Depends(get_db)):
domains = await queries.get_domains_with_severities(db)
return templates.TemplateResponse(
"index.html", {"request": request, "domains": domains}
)

View file

@ -0,0 +1 @@
<symbol viewBox="0 0 14 14" id="status_failed" xmlns="http://www.w3.org/2000/svg"><g fill-rule="evenodd"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z"></path><path d="M13 7A6 6 0 1 0 1 7a6 6 0 0 0 12 0z" fill="var(--svg-status-bg, #fff)"></path><path d="M7 5.969L5.599 4.568a.29.29 0 0 0-.413.004l-.614.614a.294.294 0 0 0-.004.413L5.968 7l-1.4 1.401a.29.29 0 0 0 .004.413l.614.614c.113.114.3.117.413.004L7 8.032l1.401 1.4a.29.29 0 0 0 .413-.004l.614-.614a.294.294 0 0 0 .004-.413L8.032 7l1.4-1.401a.29.29 0 0 0-.004-.413l-.614-.614a.294.294 0 0 0-.413-.004L7 5.968z"></path></g></symbol>

View file

@ -0,0 +1 @@
<symbol viewBox="0 0 14 14" id="status_success" xmlns="http://www.w3.org/2000/svg"><g fill-rule="evenodd"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z"></path><path d="M13 7A6 6 0 1 0 1 7a6 6 0 0 0 12 0z" fill="var(--svg-status-bg, #fff)"></path><path d="M6.278 7.697L5.045 6.464a.296.296 0 0 0-.42-.002l-.613.614a.298.298 0 0 0 .002.42l1.91 1.909a.5.5 0 0 0 .703.005l.265-.265L9.997 6.04a.291.291 0 0 0-.009-.408l-.614-.614a.29.29 0 0 0-.408-.009L6.278 7.697z"></path></g></symbol>

2675
argos/server/static/pico.css Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
@import url("pico.css");

View file

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Argos</title>
<link href="{{ url_for('static', path='/styles.css') }}" rel="stylesheet">
</head>
<body>
<main class="container">
<div id="header">
<a href="/"><h1 id="title">Argos monitoring</h1></a>
</div>
<div id="content">
{% block content %}
{% endblock %}
</div>
</main>
</body>
</html>

View file

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block content %}
<div id="domains" class="frame">
<table id="domains-list">
<thead>
<tr>
<th>Domain</th>
<th class="today">Current status</th>
</thead>
<tbody id="domains-body">
{% for domain, status in domains.items() %}
<tr>
<td>
<a href="/">
{{ domain }} </a>
</td>
<td class="status highlight">{% if status == "ok" %}✅{% elif statuts == "warning"%}⚠{% elif status == "critical"%}❌{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View file

@ -1,5 +1,5 @@
general:
frequency: "1h" # Run checks every 4 hours.
frequency: "1m" # Run checks every minute.
alerts:
error:
- local
@ -8,7 +8,6 @@ general:
alert:
- local
service:
port: 8888
secrets:
# Secrets can be generated using `openssl rand -base64 32`.
- "O4kt8Max9/k0EmHaEJ0CGGYbBNFmK8kOZNIoUk3Kjwc"

View file

@ -14,7 +14,7 @@ def test_db():
def test_read_tasks_requires_auth():
with TestClient(app) as client:
response = client.get("/tasks")
response = client.get("/api/tasks")
assert response.status_code == 403
@ -22,7 +22,7 @@ def test_tasks_retrieval_and_results(test_db):
with TestClient(app) as client:
token = app.state.config.service.secrets[0]
client.headers = {"Authorization": f"Bearer {token}"}
response = client.get("/tasks")
response = client.get("/api/tasks")
assert response.status_code == 200
tasks = response.json()
@ -35,7 +35,11 @@ def test_tasks_retrieval_and_results(test_db):
)
data = [r.model_dump() for r in results]
response = client.post("/results", json=data)
response = client.post("/api/results", json=data)
assert response.status_code == 201
assert test_db.query(models.Result).count() == 2
# The list of tasks should be empty now
response = client.get("/api/tasks")
assert len(response.json()) == 0