mirror of
https://framagit.org/framasoft/framaspace/argos.git
synced 2025-04-28 09:52:38 +02:00
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:
parent
e2d8066746
commit
83f57c6e47
20 changed files with 2872 additions and 54 deletions
|
@ -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) ;
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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()}
|
||||
|
|
2
argos/server/routes/__init__.py
Normal file
2
argos/server/routes/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
from .api import route as api
|
||||
from .views import route as views
|
|
@ -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
|
24
argos/server/routes/dependencies.py
Normal file
24
argos/server/routes/dependencies.py
Normal 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
|
19
argos/server/routes/views.py
Normal file
19
argos/server/routes/views.py
Normal 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}
|
||||
)
|
1
argos/server/static/images/error.svg
Normal file
1
argos/server/static/images/error.svg
Normal 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>
|
1
argos/server/static/images/success.svg
Normal file
1
argos/server/static/images/success.svg
Normal 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
2675
argos/server/static/pico.css
Normal file
File diff suppressed because it is too large
Load diff
1
argos/server/static/styles.css
Normal file
1
argos/server/static/styles.css
Normal file
|
@ -0,0 +1 @@
|
|||
@import url("pico.css");
|
20
argos/server/templates/base.html
Normal file
20
argos/server/templates/base.html
Normal 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>
|
27
argos/server/templates/index.html
Normal file
27
argos/server/templates/index.html
Normal 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 %}
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue