mirror of
https://framagit.org/framasoft/framaspace/argos.git
synced 2025-04-28 18:02:41 +02:00
🔀 Merge remote-tracking branch 'origin/develop'
This commit is contained in:
commit
dec6c72238
28 changed files with 564 additions and 143 deletions
|
@ -37,6 +37,12 @@ djlint:
|
||||||
script:
|
script:
|
||||||
- make djlint
|
- make djlint
|
||||||
|
|
||||||
|
mypy:
|
||||||
|
<<: *pull_cache
|
||||||
|
stage: test
|
||||||
|
script:
|
||||||
|
- make mypy
|
||||||
|
|
||||||
pylint:
|
pylint:
|
||||||
<<: *pull_cache
|
<<: *pull_cache
|
||||||
stage: test
|
stage: test
|
||||||
|
@ -61,6 +67,10 @@ release_job:
|
||||||
release: # See https://docs.gitlab.com/ee/ci/yaml/#release for available properties
|
release: # See https://docs.gitlab.com/ee/ci/yaml/#release for available properties
|
||||||
tag_name: '$CI_COMMIT_TAG'
|
tag_name: '$CI_COMMIT_TAG'
|
||||||
description: './release.md'
|
description: './release.md'
|
||||||
|
assets:
|
||||||
|
links:
|
||||||
|
- name: 'PyPI page'
|
||||||
|
url: 'https://pypi.org/project/argos-monitoring/$CI_COMMIT_TAG/'
|
||||||
|
|
||||||
pages:
|
pages:
|
||||||
<<: *pull_cache
|
<<: *pull_cache
|
||||||
|
|
10
CHANGELOG.md
10
CHANGELOG.md
|
@ -2,6 +2,16 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- 🩹 — Fix release documentation
|
||||||
|
- ✅ — Add mypy test
|
||||||
|
- ✨ — Add new check type: status-in
|
||||||
|
- 🩹 — Close menu after rescheduling non-ok checks (#55)
|
||||||
|
- ✨ — Add new check types: headers-contain and headers-have (#56)
|
||||||
|
- ✨ — Add command to test email configuration (!66)
|
||||||
|
- 💄 — Enhance the mobile view (!67)
|
||||||
|
- ✨ — Allow to run Argos in a subfolder (i.e. not on /) (#59)
|
||||||
|
- ✨ — Add new check types: json-contains, json-has and json-is (#57)
|
||||||
|
|
||||||
## 0.2.2
|
## 0.2.2
|
||||||
|
|
||||||
Date: 2024-07-04
|
Date: 2024-07-04
|
||||||
|
|
6
Makefile
6
Makefile
|
@ -25,10 +25,12 @@ ruff: venv
|
||||||
ruff-format: venv
|
ruff-format: venv
|
||||||
venv/bin/ruff format .
|
venv/bin/ruff format .
|
||||||
djlint: venv ## Format the templates
|
djlint: venv ## Format the templates
|
||||||
venv/bin/djlint --ignore=H030,H031,H006 --profile jinja --lint argos/server/templates/*html
|
venv/bin/djlint --ignore=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 ruff
|
mypy: venv
|
||||||
|
venv/bin/mypy argos tests
|
||||||
|
lint: djlint pylint mypy ruff
|
||||||
help:
|
help:
|
||||||
@python3 -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST)
|
@python3 -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST)
|
||||||
|
|
||||||
|
|
|
@ -54,12 +54,12 @@ class ArgosAgent:
|
||||||
while "forever":
|
while "forever":
|
||||||
retry_now = await self._get_and_complete_tasks()
|
retry_now = await self._get_and_complete_tasks()
|
||||||
if not retry_now:
|
if not retry_now:
|
||||||
logger.error("Waiting %i seconds before next retry", self.wait_time)
|
logger.info("Waiting %i seconds before next retry", self.wait_time)
|
||||||
await asyncio.sleep(self.wait_time)
|
await asyncio.sleep(self.wait_time)
|
||||||
|
|
||||||
async def _complete_task(self, task: dict) -> dict:
|
async def _complete_task(self, _task: dict) -> AgentResult:
|
||||||
try:
|
try:
|
||||||
task = Task(**task)
|
task = Task(**_task)
|
||||||
check_class = get_registered_check(task.check)
|
check_class = get_registered_check(task.check)
|
||||||
check = check_class(self._http_client, task)
|
check = check_class(self._http_client, task)
|
||||||
result = await check.run()
|
result = await check.run()
|
||||||
|
@ -69,7 +69,7 @@ class ArgosAgent:
|
||||||
except Exception as err: # pylint: disable=broad-except
|
except Exception as err: # pylint: disable=broad-except
|
||||||
status = "error"
|
status = "error"
|
||||||
context = SerializableException.from_exception(err)
|
context = SerializableException.from_exception(err)
|
||||||
msg = f"An exception occured when running {task}. {err.__class__.__name__} : {err}"
|
msg = f"An exception occured when running {_task}. {err.__class__.__name__} : {err}"
|
||||||
logger.error(msg)
|
logger.error(msg)
|
||||||
return AgentResult(task_id=task.id, status=status, context=context)
|
return AgentResult(task_id=task.id, status=status, context=context)
|
||||||
|
|
||||||
|
@ -94,7 +94,7 @@ class ArgosAgent:
|
||||||
await self._post_results(results)
|
await self._post_results(results)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
logger.error("Got no tasks from the server.")
|
logger.info("Got no tasks from the server.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
logger.error("Failed to fetch tasks: %s", response.read())
|
logger.error("Failed to fetch tasks: %s", response.read())
|
||||||
|
@ -102,12 +102,19 @@ class ArgosAgent:
|
||||||
|
|
||||||
async def _post_results(self, results: List[AgentResult]):
|
async def _post_results(self, results: List[AgentResult]):
|
||||||
data = [r.model_dump() for r in results]
|
data = [r.model_dump() for r in results]
|
||||||
|
if self._http_client is not None:
|
||||||
response = await self._http_client.post(
|
response = await self._http_client.post(
|
||||||
f"{self.server}/api/results", params={"agent_id": self.agent_id}, json=data
|
f"{self.server}/api/results",
|
||||||
|
params={"agent_id": self.agent_id},
|
||||||
|
json=data,
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code == httpx.codes.CREATED:
|
if response.status_code == httpx.codes.CREATED:
|
||||||
logger.error("Successfully posted results %s", json.dumps(response.json()))
|
logger.info(
|
||||||
|
"Successfully posted results %s", json.dumps(response.json())
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.error("Failed to post results: %s", response.read())
|
logger.error("Failed to post results: %s", response.read())
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
logger.error("self._http_client is None")
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
"""Various base classes for checks"""
|
"""Various base classes for checks"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Type, Union
|
from typing import Type
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
@ -71,7 +71,7 @@ class InvalidResponse(Exception):
|
||||||
|
|
||||||
class BaseCheck:
|
class BaseCheck:
|
||||||
config: str
|
config: str
|
||||||
expected_cls: Union[None, Type[BaseExpectedValue]] = None
|
expected_cls: None | Type[BaseExpectedValue] = None
|
||||||
|
|
||||||
_registry = [] # type: ignore[var-annotated]
|
_registry = [] # type: ignore[var-annotated]
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
"""Define the available checks"""
|
"""Define the available checks"""
|
||||||
|
|
||||||
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from jsonpointer import resolve_pointer, JsonPointerException
|
||||||
|
|
||||||
from argos.checks.base import (
|
from argos.checks.base import (
|
||||||
BaseCheck,
|
BaseCheck,
|
||||||
ExpectedIntValue,
|
ExpectedIntValue,
|
||||||
|
@ -31,6 +34,82 @@ class HTTPStatus(BaseCheck):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPStatusIn(BaseCheck):
|
||||||
|
"""Checks that the HTTP status code is in the list of expected values."""
|
||||||
|
|
||||||
|
config = "status-in"
|
||||||
|
expected_cls = ExpectedStringValue
|
||||||
|
|
||||||
|
async def run(self) -> dict:
|
||||||
|
# XXX Get the method from the task
|
||||||
|
task = self.task
|
||||||
|
response = await self.http_client.request(
|
||||||
|
method="get", url=task.url, timeout=60
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.response(
|
||||||
|
status=response.status_code in json.loads(self.expected),
|
||||||
|
expected=self.expected,
|
||||||
|
retrieved=response.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPHeadersContain(BaseCheck):
|
||||||
|
"""Checks that response headers contains the expected headers
|
||||||
|
(without checking their values)"""
|
||||||
|
|
||||||
|
config = "headers-contain"
|
||||||
|
expected_cls = ExpectedStringValue
|
||||||
|
|
||||||
|
async def run(self) -> dict:
|
||||||
|
# XXX Get the method from the task
|
||||||
|
task = self.task
|
||||||
|
response = await self.http_client.request(
|
||||||
|
method="get", url=task.url, timeout=60
|
||||||
|
)
|
||||||
|
|
||||||
|
status = True
|
||||||
|
for header in json.loads(self.expected):
|
||||||
|
if header not in response.headers:
|
||||||
|
status = False
|
||||||
|
break
|
||||||
|
|
||||||
|
return self.response(
|
||||||
|
status=status,
|
||||||
|
expected=self.expected,
|
||||||
|
retrieved=json.dumps(list(dict(response.headers).keys())),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPHeadersHave(BaseCheck):
|
||||||
|
"""Checks that response headers contains the expected headers and values"""
|
||||||
|
|
||||||
|
config = "headers-have"
|
||||||
|
expected_cls = ExpectedStringValue
|
||||||
|
|
||||||
|
async def run(self) -> dict:
|
||||||
|
# XXX Get the method from the task
|
||||||
|
task = self.task
|
||||||
|
response = await self.http_client.request(
|
||||||
|
method="get", url=task.url, timeout=60
|
||||||
|
)
|
||||||
|
|
||||||
|
status = True
|
||||||
|
for header, value in json.loads(self.expected).items():
|
||||||
|
if header not in response.headers:
|
||||||
|
status = False
|
||||||
|
break
|
||||||
|
if response.headers[header] != value:
|
||||||
|
status = False
|
||||||
|
break
|
||||||
|
|
||||||
|
return self.response(
|
||||||
|
status=status,
|
||||||
|
expected=self.expected,
|
||||||
|
retrieved=json.dumps(dict(response.headers)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class HTTPBodyContains(BaseCheck):
|
class HTTPBodyContains(BaseCheck):
|
||||||
"""Checks that the HTTP body contains the expected string."""
|
"""Checks that the HTTP body contains the expected string."""
|
||||||
|
|
||||||
|
@ -44,6 +123,94 @@ class HTTPBodyContains(BaseCheck):
|
||||||
return self.response(status=self.expected in response.text)
|
return self.response(status=self.expected in response.text)
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPJsonContains(BaseCheck):
|
||||||
|
"""Checks that JSON response contains the expected structure
|
||||||
|
(without checking the value)"""
|
||||||
|
|
||||||
|
config = "json-contains"
|
||||||
|
expected_cls = ExpectedStringValue
|
||||||
|
|
||||||
|
async def run(self) -> dict:
|
||||||
|
# XXX Get the method from the task
|
||||||
|
task = self.task
|
||||||
|
response = await self.http_client.request(
|
||||||
|
method="get", url=task.url, timeout=60
|
||||||
|
)
|
||||||
|
|
||||||
|
obj = response.json()
|
||||||
|
|
||||||
|
status = True
|
||||||
|
for pointer in json.loads(self.expected):
|
||||||
|
try:
|
||||||
|
resolve_pointer(obj, pointer)
|
||||||
|
except JsonPointerException:
|
||||||
|
status = False
|
||||||
|
break
|
||||||
|
|
||||||
|
return self.response(
|
||||||
|
status=status,
|
||||||
|
expected=self.expected,
|
||||||
|
retrieved=json.dumps(obj),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPJsonHas(BaseCheck):
|
||||||
|
"""Checks that JSON response contains the expected structure and values"""
|
||||||
|
|
||||||
|
config = "json-has"
|
||||||
|
expected_cls = ExpectedStringValue
|
||||||
|
|
||||||
|
async def run(self) -> dict:
|
||||||
|
# XXX Get the method from the task
|
||||||
|
task = self.task
|
||||||
|
response = await self.http_client.request(
|
||||||
|
method="get", url=task.url, timeout=60
|
||||||
|
)
|
||||||
|
|
||||||
|
obj = response.json()
|
||||||
|
|
||||||
|
status = True
|
||||||
|
for pointer, exp_value in json.loads(self.expected).items():
|
||||||
|
try:
|
||||||
|
value = resolve_pointer(obj, pointer)
|
||||||
|
if value != exp_value:
|
||||||
|
status = False
|
||||||
|
break
|
||||||
|
except JsonPointerException:
|
||||||
|
status = False
|
||||||
|
break
|
||||||
|
|
||||||
|
return self.response(
|
||||||
|
status=status,
|
||||||
|
expected=self.expected,
|
||||||
|
retrieved=json.dumps(obj),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPJsonIs(BaseCheck):
|
||||||
|
"""Checks that JSON response is the exact expected JSON object"""
|
||||||
|
|
||||||
|
config = "json-is"
|
||||||
|
expected_cls = ExpectedStringValue
|
||||||
|
|
||||||
|
async def run(self) -> dict:
|
||||||
|
# XXX Get the method from the task
|
||||||
|
task = self.task
|
||||||
|
response = await self.http_client.request(
|
||||||
|
method="get", url=task.url, timeout=60
|
||||||
|
)
|
||||||
|
|
||||||
|
obj = response.json()
|
||||||
|
|
||||||
|
status = response.json() == json.loads(self.expected)
|
||||||
|
|
||||||
|
return self.response(
|
||||||
|
status=status,
|
||||||
|
expected=self.expected,
|
||||||
|
retrieved=json.dumps(obj),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SSLCertificateExpiration(BaseCheck):
|
class SSLCertificateExpiration(BaseCheck):
|
||||||
"""Checks that the SSL certificate will not expire soon."""
|
"""Checks that the SSL certificate will not expire soon."""
|
||||||
|
|
||||||
|
|
|
@ -10,8 +10,7 @@ import uvicorn
|
||||||
from alembic import command
|
from alembic import command
|
||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
|
|
||||||
from argos import logging
|
from argos import VERSION, logging
|
||||||
from argos import VERSION
|
|
||||||
from argos.agent import ArgosAgent
|
from argos.agent import ArgosAgent
|
||||||
|
|
||||||
|
|
||||||
|
@ -305,9 +304,10 @@ async def add(config, name, password):
|
||||||
os.environ["ARGOS_YAML_FILE"] = config
|
os.environ["ARGOS_YAML_FILE"] = config
|
||||||
|
|
||||||
# The imports are made here otherwise the agent will need server configuration files.
|
# The imports are made here otherwise the agent will need server configuration files.
|
||||||
from argos.server import queries
|
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
from argos.server import queries
|
||||||
|
|
||||||
db = await get_db()
|
db = await get_db()
|
||||||
_user = await queries.get_user(db, name)
|
_user = await queries.get_user(db, name)
|
||||||
if _user is not None:
|
if _user is not None:
|
||||||
|
@ -339,9 +339,10 @@ async def change_password(config, name, password):
|
||||||
os.environ["ARGOS_YAML_FILE"] = config
|
os.environ["ARGOS_YAML_FILE"] = config
|
||||||
|
|
||||||
# The imports are made here otherwise the agent will need server configuration files.
|
# The imports are made here otherwise the agent will need server configuration files.
|
||||||
from argos.server import queries
|
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
from argos.server import queries
|
||||||
|
|
||||||
db = await get_db()
|
db = await get_db()
|
||||||
_user = await queries.get_user(db, name)
|
_user = await queries.get_user(db, name)
|
||||||
if _user is None:
|
if _user is None:
|
||||||
|
@ -374,9 +375,10 @@ async def verify_password(config, name, password):
|
||||||
os.environ["ARGOS_YAML_FILE"] = config
|
os.environ["ARGOS_YAML_FILE"] = config
|
||||||
|
|
||||||
# The imports are made here otherwise the agent will need server configuration files.
|
# The imports are made here otherwise the agent will need server configuration files.
|
||||||
from argos.server import queries
|
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
from argos.server import queries
|
||||||
|
|
||||||
db = await get_db()
|
db = await get_db()
|
||||||
_user = await queries.get_user(db, name)
|
_user = await queries.get_user(db, name)
|
||||||
if _user is None:
|
if _user is None:
|
||||||
|
@ -548,5 +550,69 @@ async def generate_config():
|
||||||
print(f.read())
|
print(f.read())
|
||||||
|
|
||||||
|
|
||||||
|
@server.command()
|
||||||
|
@click.option(
|
||||||
|
"--config",
|
||||||
|
default="argos-config.yaml",
|
||||||
|
help="Path of the configuration file. "
|
||||||
|
"If ARGOS_YAML_FILE environment variable is set, its value will be used instead.",
|
||||||
|
envvar="ARGOS_YAML_FILE",
|
||||||
|
callback=validate_config_access,
|
||||||
|
)
|
||||||
|
@click.option("--domain", help="Domain for the notification", default="example.org")
|
||||||
|
@click.option("--severity", help="Severity", default="CRITICAL")
|
||||||
|
@coroutine
|
||||||
|
async def test_mail(config, domain, severity):
|
||||||
|
"""Send a test email"""
|
||||||
|
os.environ["ARGOS_YAML_FILE"] = config
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from argos.logging import set_log_level
|
||||||
|
from argos.server.alerting import notify_by_mail
|
||||||
|
from argos.server.main import read_config
|
||||||
|
from argos.server.models import Result, Task
|
||||||
|
|
||||||
|
conf = read_config(config)
|
||||||
|
|
||||||
|
if not conf.general.mail:
|
||||||
|
click.echo("Mail is not configured, cannot test", err=True)
|
||||||
|
sysexit(1)
|
||||||
|
else:
|
||||||
|
now = datetime.now()
|
||||||
|
task = Task(
|
||||||
|
url=f"https://{domain}",
|
||||||
|
domain=domain,
|
||||||
|
check="body-contains",
|
||||||
|
expected="foo",
|
||||||
|
frequency=1,
|
||||||
|
selected_by="test",
|
||||||
|
selected_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = Result(
|
||||||
|
submitted_at=now,
|
||||||
|
status="success",
|
||||||
|
context={"foo": "bar"},
|
||||||
|
task=task,
|
||||||
|
agent_id="test",
|
||||||
|
severity="ok",
|
||||||
|
)
|
||||||
|
|
||||||
|
class _FalseRequest:
|
||||||
|
def url_for(*args, **kwargs):
|
||||||
|
return "/url"
|
||||||
|
|
||||||
|
set_log_level("debug")
|
||||||
|
notify_by_mail(
|
||||||
|
result,
|
||||||
|
task,
|
||||||
|
severity="SEVERITY",
|
||||||
|
old_severity="OLD SEVERITY",
|
||||||
|
config=conf.general.mail,
|
||||||
|
request=_FalseRequest(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
cli()
|
cli()
|
||||||
|
|
|
@ -1,10 +1,14 @@
|
||||||
|
---
|
||||||
general:
|
general:
|
||||||
db:
|
db:
|
||||||
# The database URL, as defined in SQLAlchemy docs : https://docs.sqlalchemy.org/en/20/core/engines.html#database-urls
|
# The database URL, as defined in SQLAlchemy docs :
|
||||||
|
# https://docs.sqlalchemy.org/en/20/core/engines.html#database-urls
|
||||||
# Example for SQLite: "sqlite:////tmp/argos.db"
|
# Example for SQLite: "sqlite:////tmp/argos.db"
|
||||||
url: "postgresql://argos:argos@localhost/argos"
|
url: "postgresql://argos:argos@localhost/argos"
|
||||||
# You configure the size of the database pool of connection, and the max overflow (until when new connections are accepted ?)
|
# You configure the size of the database pool of connection, and
|
||||||
# See https://docs.sqlalchemy.org/en/20/core/pooling.html#sqlalchemy.pool.QueuePool.params.pool_size for details
|
# the max overflow (until when new connections are accepted ?)
|
||||||
|
# For details, see
|
||||||
|
# https://docs.sqlalchemy.org/en/20/core/pooling.html#sqlalchemy.pool.QueuePool.params.pool_size
|
||||||
pool_size: 10
|
pool_size: 10
|
||||||
max_overflow: 20
|
max_overflow: 20
|
||||||
# Can be "production", "dev", "test".
|
# Can be "production", "dev", "test".
|
||||||
|
@ -29,6 +33,11 @@ general:
|
||||||
- local
|
- local
|
||||||
unknown:
|
unknown:
|
||||||
- local
|
- local
|
||||||
|
# Argos root path
|
||||||
|
# If not present, default value is ""
|
||||||
|
# Set it to /foo if you want to use argos at /foo/ instead of /
|
||||||
|
# on your web server
|
||||||
|
# root_path: "/foo"
|
||||||
# Mail configuration is quite straight-forward
|
# Mail configuration is quite straight-forward
|
||||||
# mail:
|
# mail:
|
||||||
# mailfrom: no-reply@example.org
|
# mailfrom: no-reply@example.org
|
||||||
|
@ -71,12 +80,45 @@ websites:
|
||||||
paths:
|
paths:
|
||||||
- path: "/mypads/"
|
- path: "/mypads/"
|
||||||
checks:
|
checks:
|
||||||
|
# Check that the returned HTTP status is 200
|
||||||
- status-is: 200
|
- status-is: 200
|
||||||
|
# Check that the response contains this string
|
||||||
- body-contains: '<div id= "mypads"></div>'
|
- body-contains: '<div id= "mypads"></div>'
|
||||||
|
# Check that the SSL certificate is no older than ssl.thresholds
|
||||||
- ssl-certificate-expiration: "on-check"
|
- ssl-certificate-expiration: "on-check"
|
||||||
|
# Check that the response contains this headers
|
||||||
|
# The comparison is case insensitive
|
||||||
|
- headers-contain:
|
||||||
|
- "content-encoding"
|
||||||
|
- "content-type"
|
||||||
- path: "/admin/"
|
- path: "/admin/"
|
||||||
checks:
|
checks:
|
||||||
- status-is: 401
|
# Check that the return HTTP status is one of those
|
||||||
|
# Similar to status-is, verify that you don’t mistyped it!
|
||||||
|
- status-in:
|
||||||
|
- 401
|
||||||
|
- 301
|
||||||
|
# Check that the response contains this headers and values
|
||||||
|
# It’s VERY important to respect the 4 spaces indentation here!
|
||||||
|
# The name of the headers is case insensitive
|
||||||
|
- headers-have:
|
||||||
|
content-encoding: "gzip"
|
||||||
|
content-type: "text/html"
|
||||||
|
- path: "/my-stats.json"
|
||||||
|
checks:
|
||||||
|
# Check that JSON response contains the expected structure
|
||||||
|
- json-contains:
|
||||||
|
- /foo/bar/0
|
||||||
|
- /foo/bar/1
|
||||||
|
- /timestamp
|
||||||
|
# Check that JSON response contains the expected structure and values
|
||||||
|
# It’s VERY important to respect the 4 spaces indentation here!
|
||||||
|
- json-has:
|
||||||
|
/maintenance: false
|
||||||
|
/productname: "Nextcloud"
|
||||||
|
# Check that JSON response is the exact expected JSON object
|
||||||
|
# The order of the items in the object does not matter.
|
||||||
|
- json-is: '{"foo": "bar", "baz": 42}'
|
||||||
- domain: "https://munin.example.org"
|
- domain: "https://munin.example.org"
|
||||||
frequency: "20m"
|
frequency: "20m"
|
||||||
paths:
|
paths:
|
||||||
|
|
|
@ -2,6 +2,9 @@
|
||||||
|
|
||||||
For database models, see argos.server.models.
|
For database models, see argos.server.models.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
from typing import Dict, List, Literal, Optional, Tuple
|
from typing import Dict, List, Literal, Optional, Tuple
|
||||||
|
|
||||||
from pydantic import (
|
from pydantic import (
|
||||||
|
@ -78,6 +81,10 @@ def parse_checks(value):
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
if isinstance(expected, int):
|
if isinstance(expected, int):
|
||||||
expected = str(expected)
|
expected = str(expected)
|
||||||
|
if isinstance(expected, list):
|
||||||
|
expected = json.dumps(expected)
|
||||||
|
if isinstance(expected, dict):
|
||||||
|
expected = json.dumps(expected)
|
||||||
return (name, expected)
|
return (name, expected)
|
||||||
|
|
||||||
|
|
||||||
|
@ -157,6 +164,7 @@ class General(BaseModel):
|
||||||
frequency: int
|
frequency: int
|
||||||
db: DbSettings
|
db: DbSettings
|
||||||
env: Environment = "production"
|
env: Environment = "production"
|
||||||
|
root_path: str = ""
|
||||||
alerts: Alert
|
alerts: Alert
|
||||||
mail: Optional[Mail] = None
|
mail: Optional[Mail] = None
|
||||||
gotify: Optional[List[GotifyUrl]] = None
|
gotify: Optional[List[GotifyUrl]] = None
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
from typing import Literal, Union
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
def string_to_duration(
|
def string_to_duration(
|
||||||
value: str, target: Literal["days", "hours", "minutes"]
|
value: str, target: Literal["days", "hours", "minutes"]
|
||||||
) -> Union[int, float]:
|
) -> int | float:
|
||||||
"""Convert a string to a number of hours, days or minutes"""
|
"""Convert a string to a number of hours, days or minutes"""
|
||||||
num = int("".join(filter(str.isdigit, value)))
|
num = int("".join(filter(str.isdigit, value)))
|
||||||
|
|
||||||
|
|
|
@ -18,11 +18,19 @@ from argos.server.settings import read_yaml_config
|
||||||
|
|
||||||
def get_application() -> FastAPI:
|
def get_application() -> FastAPI:
|
||||||
"""Spawn Argos FastAPI server"""
|
"""Spawn Argos FastAPI server"""
|
||||||
appli = FastAPI(lifespan=lifespan)
|
|
||||||
config_file = os.environ["ARGOS_YAML_FILE"]
|
config_file = os.environ["ARGOS_YAML_FILE"]
|
||||||
|
|
||||||
config = read_config(config_file)
|
config = read_config(config_file)
|
||||||
|
|
||||||
|
root_path = config.general.root_path
|
||||||
|
|
||||||
|
if root_path != "":
|
||||||
|
logger.info("Root path for Argos: %s", root_path)
|
||||||
|
if root_path.endswith("/"):
|
||||||
|
root_path = root_path[:-1]
|
||||||
|
logger.info("Fixed root path for Argos: %s", root_path)
|
||||||
|
|
||||||
|
appli = FastAPI(lifespan=lifespan, root_path=root_path)
|
||||||
|
|
||||||
# Config is the argos config object (built from yaml)
|
# Config is the argos config object (built from yaml)
|
||||||
appli.state.config = config
|
appli.state.config = config
|
||||||
appli.add_exception_handler(NotAuthenticatedException, auth_exception_handler)
|
appli.add_exception_handler(NotAuthenticatedException, auth_exception_handler)
|
||||||
|
|
|
@ -51,7 +51,7 @@ async def list_users(db: Session):
|
||||||
return db.query(User).order_by(asc(User.username))
|
return db.query(User).order_by(asc(User.username))
|
||||||
|
|
||||||
|
|
||||||
async def get_task(db: Session, task_id: int) -> Task:
|
async def get_task(db: Session, task_id: int) -> None | Task:
|
||||||
return db.get(Task, task_id)
|
return db.get(Task, task_id)
|
||||||
|
|
||||||
|
|
||||||
|
@ -71,9 +71,9 @@ async def count_tasks(db: Session, selected: None | bool = None):
|
||||||
query = db.query(Task)
|
query = db.query(Task)
|
||||||
if selected is not None:
|
if selected is not None:
|
||||||
if selected:
|
if selected:
|
||||||
query = query.filter(Task.selected_by is not None)
|
query = query.filter(Task.selected_by is not None) # type: ignore[arg-type]
|
||||||
else:
|
else:
|
||||||
query = query.filter(Task.selected_by is None)
|
query = query.filter(Task.selected_by is None) # type: ignore[arg-type]
|
||||||
|
|
||||||
return query.count()
|
return query.count()
|
||||||
|
|
||||||
|
@ -98,7 +98,7 @@ async def has_config_changed(db: Session, config: schemas.Config) -> bool:
|
||||||
case "general_frequency":
|
case "general_frequency":
|
||||||
if conf.val != str(config.general.frequency):
|
if conf.val != str(config.general.frequency):
|
||||||
same_config = False
|
same_config = False
|
||||||
conf.val = config.general.frequency
|
conf.val = str(config.general.frequency)
|
||||||
conf.updated_at = datetime.now()
|
conf.updated_at = datetime.now()
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
@ -208,7 +208,7 @@ async def get_severity_counts(db: Session) -> dict:
|
||||||
# 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) # type: ignore[var-annotated,arg-type]
|
||||||
for key in ("ok", "warning", "critical", "unknown"):
|
for key in ("ok", "warning", "critical", "unknown"):
|
||||||
counts_dict.setdefault(key, 0)
|
counts_dict.setdefault(key, 0)
|
||||||
return counts_dict
|
return counts_dict
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
"""Web interface for machines"""
|
"""Web interface for machines"""
|
||||||
from typing import List, Union
|
from typing import List
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, Request
|
from fastapi import APIRouter, BackgroundTasks, Depends, Request
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
@ -18,10 +18,13 @@ async def read_tasks(
|
||||||
request: Request,
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
agent_id: Union[None, str] = None,
|
agent_id: None | str = None,
|
||||||
):
|
):
|
||||||
"""Return a list of tasks to execute"""
|
"""Return a list of tasks to execute"""
|
||||||
agent_id = agent_id or request.client.host
|
host = ""
|
||||||
|
if request.client is not None:
|
||||||
|
host = request.client.host
|
||||||
|
agent_id = agent_id or host
|
||||||
tasks = await queries.list_tasks(db, agent_id=agent_id, limit=limit)
|
tasks = await queries.list_tasks(db, agent_id=agent_id, limit=limit)
|
||||||
return tasks
|
return tasks
|
||||||
|
|
||||||
|
@ -33,7 +36,7 @@ async def create_results(
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
config: Config = Depends(get_config),
|
config: Config = Depends(get_config),
|
||||||
agent_id: Union[None, str] = None,
|
agent_id: None | str = None,
|
||||||
):
|
):
|
||||||
"""Get the results from the agents and store them locally.
|
"""Get the results from the agents and store them locally.
|
||||||
|
|
||||||
|
@ -42,7 +45,10 @@ async def create_results(
|
||||||
- If it's an error, determine its severity ;
|
- If it's an error, determine its severity ;
|
||||||
- Trigger the reporting calls
|
- Trigger the reporting calls
|
||||||
"""
|
"""
|
||||||
agent_id = agent_id or request.client.host
|
host = ""
|
||||||
|
if request.client is not None:
|
||||||
|
host = request.client.host
|
||||||
|
agent_id = agent_id or host
|
||||||
db_results = []
|
db_results = []
|
||||||
for agent_result in results:
|
for agent_result in results:
|
||||||
# XXX Maybe offload this to a queue.
|
# XXX Maybe offload this to a queue.
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
from fastapi import Depends, HTTPException, Request
|
from fastapi import Depends, HTTPException, Request
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from fastapi_login import LoginManager
|
||||||
|
|
||||||
auth_scheme = HTTPBearer()
|
auth_scheme = HTTPBearer()
|
||||||
|
|
||||||
|
@ -16,7 +17,7 @@ def get_config(request: Request):
|
||||||
return request.app.state.config
|
return request.app.state.config
|
||||||
|
|
||||||
|
|
||||||
async def get_manager(request: Request):
|
async def get_manager(request: Request) -> LoginManager:
|
||||||
return await request.app.state.manager(request)
|
return await request.app.state.manager(request)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -125,7 +125,7 @@ async def get_domains_view(
|
||||||
tasks = db.query(Task).all()
|
tasks = db.query(Task).all()
|
||||||
|
|
||||||
domains_severities = defaultdict(list)
|
domains_severities = defaultdict(list)
|
||||||
domains_last_checks = defaultdict(list)
|
domains_last_checks = defaultdict(list) # type: ignore[var-annotated]
|
||||||
|
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
domain = urlparse(task.url).netloc
|
domain = urlparse(task.url).netloc
|
||||||
|
@ -210,6 +210,8 @@ async def get_task_results_view(
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
task = db.query(Task).get(task_id)
|
task = db.query(Task).get(task_id)
|
||||||
|
description = ""
|
||||||
|
if task is not None:
|
||||||
description = task.get_check().get_description(config)
|
description = task.get_check().get_description(config)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"results.html",
|
"results.html",
|
||||||
|
@ -251,8 +253,8 @@ async def set_refresh_cookies_view(
|
||||||
request.url_for("get_severity_counts_view"),
|
request.url_for("get_severity_counts_view"),
|
||||||
status_code=status.HTTP_303_SEE_OTHER,
|
status_code=status.HTTP_303_SEE_OTHER,
|
||||||
)
|
)
|
||||||
response.set_cookie(key="auto_refresh_enabled", value=auto_refresh_enabled)
|
response.set_cookie(key="auto_refresh_enabled", value=str(auto_refresh_enabled))
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key="auto_refresh_seconds", value=max(5, int(auto_refresh_seconds))
|
key="auto_refresh_seconds", value=str(max(5, int(auto_refresh_seconds)))
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|
|
@ -7,12 +7,12 @@ from yamlinclude import YamlIncludeConstructor
|
||||||
from argos.schemas.config import Config
|
from argos.schemas.config import Config
|
||||||
|
|
||||||
|
|
||||||
def read_yaml_config(filename):
|
def read_yaml_config(filename: str) -> Config:
|
||||||
parsed = _load_yaml(filename)
|
parsed = _load_yaml(filename)
|
||||||
return Config(**parsed)
|
return Config(**parsed)
|
||||||
|
|
||||||
|
|
||||||
def _load_yaml(filename):
|
def _load_yaml(filename: str):
|
||||||
base_dir = Path(filename).resolve().parent
|
base_dir = Path(filename).resolve().parent
|
||||||
YamlIncludeConstructor.add_to_loader_class(
|
YamlIncludeConstructor.add_to_loader_class(
|
||||||
loader_class=yaml.FullLoader, base_dir=str(base_dir)
|
loader_class=yaml.FullLoader, base_dir=str(base_dir)
|
||||||
|
|
|
@ -4,10 +4,6 @@ code {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
body > header,
|
|
||||||
body > main {
|
|
||||||
padding: 0 !important;
|
|
||||||
}
|
|
||||||
#title {
|
#title {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
@ -53,3 +49,7 @@ label[for="select-status"] {
|
||||||
#refresh-delay {
|
#refresh-delay {
|
||||||
max-width: 120px;
|
max-width: 120px;
|
||||||
}
|
}
|
||||||
|
/* Remove chevron on menu */
|
||||||
|
#nav-menu summary::after {
|
||||||
|
background-image: none !important;
|
||||||
|
}
|
||||||
|
|
|
@ -3,6 +3,10 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Argos</title>
|
<title>Argos</title>
|
||||||
|
<meta name="description"
|
||||||
|
content="Argos monitoring">
|
||||||
|
<meta name="keywords"
|
||||||
|
content="argos, monitoring">
|
||||||
<link rel="shortcut icon"
|
<link rel="shortcut icon"
|
||||||
href="{{ url_for('static', path='/logo.png') }}">
|
href="{{ url_for('static', path='/logo.png') }}">
|
||||||
<meta name="viewport"
|
<meta name="viewport"
|
||||||
|
@ -12,14 +16,14 @@
|
||||||
{% if auto_refresh_enabled %}
|
{% if auto_refresh_enabled %}
|
||||||
<meta http-equiv="refresh"
|
<meta http-equiv="refresh"
|
||||||
content="{{ auto_refresh_seconds }}">
|
content="{{ auto_refresh_seconds }}">
|
||||||
{% endif %}
|
{%- endif %}
|
||||||
<link rel="stylesheet"
|
<link rel="stylesheet"
|
||||||
href="{{ url_for('static', path='/styles.css') }}">
|
href="{{ url_for('static', path='/styles.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="container">
|
<header class="container">
|
||||||
<nav>
|
<nav>
|
||||||
<a href="{{ url_for('get_severity_counts_view') }}">
|
<a href="{{ url_for("get_severity_counts_view") }}">
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<img src="{{ url_for('static', path='/logo-64.png') }}"
|
<img src="{{ url_for('static', path='/logo-64.png') }}"
|
||||||
|
@ -34,9 +38,10 @@
|
||||||
</a>
|
</a>
|
||||||
{% if request.url.remove_query_params('msg') != url_for('login_view') %}
|
{% if request.url.remove_query_params('msg') != url_for('login_view') %}
|
||||||
<ul>
|
<ul>
|
||||||
<details class="dropdown">
|
<li>
|
||||||
|
<details id="nav-menu" class="dropdown">
|
||||||
<summary autofocus>Menu</summary>
|
<summary autofocus>Menu</summary>
|
||||||
<ul>
|
<ul dir="rtl">
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('get_severity_counts_view') }}"
|
<a href="{{ url_for('get_severity_counts_view') }}"
|
||||||
class="outline {{ 'contrast' if request.url == url_for('get_severity_counts_view') }}"
|
class="outline {{ 'contrast' if request.url == url_for('get_severity_counts_view') }}"
|
||||||
|
@ -76,6 +81,7 @@
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
|
@ -97,10 +103,11 @@
|
||||||
(<a href="https://framagit.org/framasoft/framaspace/argos">sources</a>)
|
(<a href="https://framagit.org/framasoft/framaspace/argos">sources</a>)
|
||||||
<br>
|
<br>
|
||||||
API documentation:
|
API documentation:
|
||||||
<a href="{{ url_for('get_severity_counts_view') }}docs">Swagger</a>
|
<a href="{{ url_for("get_severity_counts_view") }}docs">Swagger</a>
|
||||||
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>
|
||||||
|
{% if request.url.remove_query_params('msg') != url_for('login_view') %}
|
||||||
<script>
|
<script>
|
||||||
async function rescheduleAll() {
|
async function rescheduleAll() {
|
||||||
const response = await fetch('{{ url_for("reschedule_all") }}', {method: 'POST'});
|
const response = await fetch('{{ url_for("reschedule_all") }}', {method: 'POST'});
|
||||||
|
@ -115,7 +122,9 @@
|
||||||
document.getElementById('reschedule-all').addEventListener('click', event => {
|
document.getElementById('reschedule-all').addEventListener('click', event => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
rescheduleAll();
|
rescheduleAll();
|
||||||
|
document.getElementById('nav-menu').open = false;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
{% endif %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -1,24 +1,23 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}<h2>{{ domain }}</h2>{% endblock title %}
|
{% block title %}<h2>{{ domain }}</h2>{% endblock title %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div id="domains" class="frame">
|
<div id="domains" class="overflow-auto">
|
||||||
<table id="domains-list" role="grid">
|
<table id="domains-list" role="grid" class="striped">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>URL</th>
|
<th scope="col">URL</th>
|
||||||
<th>Check</th>
|
<th scope="col">Check</th>
|
||||||
<th>Expected</th>
|
<th scope="col">Current status</th>
|
||||||
<th>Current status</th>
|
<th scope="col">Expected</th>
|
||||||
<th></th>
|
<th scope="col"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody id="domains-body">
|
<tbody id="domains-body">
|
||||||
{% for task in tasks %}
|
{% for task in tasks %}
|
||||||
<tr>
|
<tr scope="row">
|
||||||
<td>{{ task.url }}</td>
|
<td>{{ task.url }}</td>
|
||||||
<td>{{ task.check }}</td>
|
<td>{{ task.check }}</td>
|
||||||
<td>{{ task.expected }}</td>
|
|
||||||
<td class="status highlight">
|
<td class="status highlight">
|
||||||
{% if task.status %}
|
{% if task.status %}
|
||||||
<a data-tooltip="Completed at {{ task.completed_at }}"
|
<a data-tooltip="Completed at {{ task.completed_at }}"
|
||||||
|
@ -37,6 +36,7 @@
|
||||||
Waiting to be checked
|
Waiting to be checked
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
<td>{{ task.expected }}</td>
|
||||||
<td><a href="{{ url_for('get_task_results_view', task_id=task.id) }}">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 %}
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}<h2>Dashboard</h2>{% endblock title %}
|
{% block title %}
|
||||||
|
<h2>Dashboard</h2>
|
||||||
|
{% endblock title %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div id="domains" class="frame">
|
<div id="domains" class="frame">
|
||||||
<nav>
|
<nav>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('get_agents_view') }}">
|
<a href="{{ url_for("get_agents_view") }}">
|
||||||
{{ agents | length }} agent{{ 's' if agents | length > 1 }}
|
{{ agents | length }} agent{{ 's' if agents | length > 1 }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
@ -21,46 +23,55 @@
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<label class="inline-label">
|
<label class="inline-label">
|
||||||
Every <input id="refresh-delay"
|
Every
|
||||||
|
<input id="refresh-delay"
|
||||||
class="initial-width"
|
class="initial-width"
|
||||||
name="auto_refresh_seconds"
|
name="auto_refresh_seconds"
|
||||||
type="number"
|
type="number"
|
||||||
form="refresh-form"
|
form="refresh-form"
|
||||||
min="5"
|
min="5"
|
||||||
value="{{ auto_refresh_seconds }}"> seconds
|
value="{{ auto_refresh_seconds }}">
|
||||||
|
seconds
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<form id="refresh-form"
|
<form id="refresh-form"
|
||||||
method="post"
|
method="post"
|
||||||
action="{{ url_for('set_refresh_cookies_view') }}">
|
action="{{ url_for("set_refresh_cookies_view") }}">
|
||||||
<input type="Submit">
|
<input type="Submit">
|
||||||
</form>
|
</form>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="grid grid-index">
|
<div class="grid grid-index">
|
||||||
<article>
|
<article>
|
||||||
<header title="Unknown">❔</header>
|
<header title="Unknown">
|
||||||
|
❔
|
||||||
|
</header>
|
||||||
{{ counts_dict['unknown'] }}
|
{{ counts_dict['unknown'] }}
|
||||||
</article>
|
</article>
|
||||||
<article>
|
<article>
|
||||||
<header title="OK">✅</header>
|
<header title="OK">
|
||||||
|
✅
|
||||||
|
</header>
|
||||||
{{ counts_dict['ok'] }}
|
{{ counts_dict['ok'] }}
|
||||||
</article>
|
</article>
|
||||||
<article>
|
<article>
|
||||||
<header title="Warning">⚠️</header>
|
<header title="Warning">
|
||||||
|
⚠️
|
||||||
|
</header>
|
||||||
{{ counts_dict['warning'] }}
|
{{ counts_dict['warning'] }}
|
||||||
</article>
|
</article>
|
||||||
<article>
|
<article>
|
||||||
<header title="Critical">❌</header>
|
<header title="Critical">
|
||||||
|
❌
|
||||||
|
</header>
|
||||||
{{ counts_dict['critical'] }}
|
{{ counts_dict['critical'] }}
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-center">
|
<p class="text-center">
|
||||||
<a href="{{ url_for('get_domains_view') }}"
|
<a href="{{ url_for("get_domains_view") }}"
|
||||||
class="outline"
|
class="outline"
|
||||||
role="button">
|
role="button">
|
||||||
Domains
|
Domains
|
||||||
|
|
4
conf/nginx-subdirectory.conf
Normal file
4
conf/nginx-subdirectory.conf
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
location /foo/ {
|
||||||
|
include proxy_params;
|
||||||
|
proxy_pass http://127.0.0.1:8000/;
|
||||||
|
}
|
|
@ -9,7 +9,13 @@ These checks are the most basic ones. They simply check that the response from t
|
||||||
| Check | Description | Configuration |
|
| Check | Description | Configuration |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `status-is` | Check that the returned status code matches what you expect. | `status-is: "200"` |
|
| `status-is` | Check that the returned status code matches what you expect. | `status-is: "200"` |
|
||||||
|
| `status-in` | Check that the returned status code is in the list of codes you expect. | <pre><code>status-in:<br> - 200<br> - 302</code></pre> |
|
||||||
| `body-contains` | Check that the returned body contains a given string. | `body-contains: "Hello world"` |
|
| `body-contains` | Check that the returned body contains a given string. | `body-contains: "Hello world"` |
|
||||||
|
| `headers-contain` | Check that the response contains the expected headers. | <pre><code>headers-contain:<br> - "content-encoding"<br> - "content-type"</code></pre> |
|
||||||
|
| `headers-have` | Check that the response contains the expected headers with the expected value. | <pre><code>headers-have:<br> content-encoding: "gzip"<br> content-type: "text/html"</code></pre> |
|
||||||
|
| `json-contains` | Check that JSON response contains the expected structure. | <pre><code>json-contains:<br> - /foo/bar/0<br> - /timestamp</code></pre> |
|
||||||
|
| `json-has` | Check that JSON response contains the expected structure and values. | <pre><code>json-has:<br> /maintenance: false<br> /productname: "Nextcloud"</code></pre> |
|
||||||
|
| `json-is` | Check that JSON response is the exact expected JSON object | `json-is: '{"foo": "bar", "baz": 42}'`|
|
||||||
|
|
||||||
```{code-block} yaml
|
```{code-block} yaml
|
||||||
---
|
---
|
||||||
|
@ -21,6 +27,26 @@ caption: argos-config.yaml
|
||||||
checks:
|
checks:
|
||||||
- status-is: 200
|
- status-is: 200
|
||||||
- body-contains: "Hello world"
|
- body-contains: "Hello world"
|
||||||
|
- headers-contain:
|
||||||
|
- "content-encoding"
|
||||||
|
- "content-type"
|
||||||
|
- path: "/foobar"
|
||||||
|
checks:
|
||||||
|
- status-in:
|
||||||
|
- 200
|
||||||
|
- 302
|
||||||
|
# It’s VERY important to respect the 4 spaces indentation here!
|
||||||
|
- headers-have:
|
||||||
|
content-encoding: "gzip"
|
||||||
|
content-type: "text/html"
|
||||||
|
- json-contains:
|
||||||
|
- /foo/bar/0
|
||||||
|
- /timestamp
|
||||||
|
# It’s VERY important to respect the 4 spaces indentation here!
|
||||||
|
- json-has:
|
||||||
|
/maintenance: false
|
||||||
|
/productname: "Nextcloud"
|
||||||
|
- json-is: '{"foo": "bar", "baz": 42}'
|
||||||
```
|
```
|
||||||
|
|
||||||
## SSL certificate expiration
|
## SSL certificate expiration
|
||||||
|
|
26
docs/cli.md
26
docs/cli.md
|
@ -85,6 +85,7 @@ Commands:
|
||||||
migrate Run database migrations
|
migrate Run database migrations
|
||||||
reload-config Load or reload tasks’ configuration
|
reload-config Load or reload tasks’ configuration
|
||||||
start Starts the server (use only for testing or development!)
|
start Starts the server (use only for testing or development!)
|
||||||
|
test-mail Send a test email
|
||||||
user User management
|
user User management
|
||||||
watch-agents Watch agents (to run routinely)
|
watch-agents Watch agents (to run routinely)
|
||||||
```
|
```
|
||||||
|
@ -465,3 +466,28 @@ Options:
|
||||||
|
|
||||||
<!--[[[end]]]
|
<!--[[[end]]]
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
#### Test the email settings
|
||||||
|
|
||||||
|
You can verify that your mail settings are ok by sending a test email
|
||||||
|
|
||||||
|
<!--
|
||||||
|
.. [[[cog
|
||||||
|
help(["server", "test-mail", "--help"])
|
||||||
|
.. ]]] -->
|
||||||
|
|
||||||
|
```man
|
||||||
|
Usage: argos server test-mail [OPTIONS]
|
||||||
|
|
||||||
|
Send a test email
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--config TEXT Path of the configuration file. If ARGOS_YAML_FILE
|
||||||
|
environment variable is set, its value will be used instead.
|
||||||
|
--domain TEXT Domain for the notification
|
||||||
|
--severity TEXT Severity
|
||||||
|
--help Show this message and exit.
|
||||||
|
```
|
||||||
|
|
||||||
|
<!--[[[end]]]
|
||||||
|
-->
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
Argos uses a simple YAML configuration file to define the server’s configuration, the websites to monitor and the checks to run on these websites.
|
Argos uses a simple YAML configuration file to define the server’s configuration, the websites to monitor and the checks to run on these websites.
|
||||||
|
|
||||||
|
See [here](checks.md) for more informations about the checks you can use.
|
||||||
|
|
||||||
Here is a simple self-documented configuration file, which you can get with [`argos server generate-config`](cli.md#server-generate-config):
|
Here is a simple self-documented configuration file, which you can get with [`argos server generate-config`](cli.md#server-generate-config):
|
||||||
|
|
||||||
```{literalinclude} ../conf/config-example.yaml
|
```{literalinclude} ../conf/config-example.yaml
|
||||||
|
|
|
@ -6,3 +6,11 @@ Here is a example for Nginx configuration:
|
||||||
caption: /etc/nginx/sites-available/argos.example.org
|
caption: /etc/nginx/sites-available/argos.example.org
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If you want to use Argos under a subdirectory of your web server, you’ll need to set the `root_path` setting in Argos’s [configuration](../configuration.md) and set Nginx like this:
|
||||||
|
|
||||||
|
```{literalinclude} ../../conf/nginx-subdirectory.conf
|
||||||
|
---
|
||||||
|
caption: Nginx’s location for Argos in a subdirectory
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
|
@ -86,7 +86,7 @@ If you're still experimenting, you can use the [Test PyPI](https://test.pypi.org
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Publishing on test PyPI
|
# Publishing on test PyPI
|
||||||
hatch build -r test
|
hatch publish -r test
|
||||||
|
|
||||||
# Installing from test PyPI
|
# Installing from test PyPI
|
||||||
pip install --index-url https://test.pypi.org/simple/ argos-monitoring
|
pip install --index-url https://test.pypi.org/simple/ argos-monitoring
|
||||||
|
|
|
@ -26,8 +26,9 @@ dependencies = [
|
||||||
"click>=8.1,<9",
|
"click>=8.1,<9",
|
||||||
"fastapi>=0.103,<0.104",
|
"fastapi>=0.103,<0.104",
|
||||||
"fastapi-login>=1.10.0,<2",
|
"fastapi-login>=1.10.0,<2",
|
||||||
"httpx>=0.25,<1",
|
"httpx>=0.25,<0.27.0",
|
||||||
"Jinja2>=3.0,<4",
|
"Jinja2>=3.0,<4",
|
||||||
|
"jsonpointer>=3.0,<4",
|
||||||
"passlib>=1.7.4,<2",
|
"passlib>=1.7.4,<2",
|
||||||
"psycopg2-binary>=2.9,<3",
|
"psycopg2-binary>=2.9,<3",
|
||||||
"pydantic[email]>=2.4,<3",
|
"pydantic[email]>=2.4,<3",
|
||||||
|
@ -45,16 +46,18 @@ dependencies = [
|
||||||
dev = [
|
dev = [
|
||||||
"black==23.3.0",
|
"black==23.3.0",
|
||||||
"djlint>=1.34.0",
|
"djlint>=1.34.0",
|
||||||
|
"hatch==1.9.4",
|
||||||
"ipdb>=0.13,<0.14",
|
"ipdb>=0.13,<0.14",
|
||||||
"ipython>=8.16,<9",
|
"ipython>=8.16,<9",
|
||||||
"isort==5.11.5",
|
"isort==5.11.5",
|
||||||
|
"mypy>=1.10.0,<2",
|
||||||
"pylint>=3.0.2",
|
"pylint>=3.0.2",
|
||||||
"pytest-asyncio>=0.21,<1",
|
"pytest-asyncio>=0.21,<1",
|
||||||
"pytest>=6.2.5",
|
"pytest>=6.2.5",
|
||||||
"respx>=0.20,<1",
|
"respx>=0.20,<1",
|
||||||
"ruff==0.1.5,<1",
|
"ruff==0.1.5,<1",
|
||||||
"sphinx-autobuild",
|
"sphinx-autobuild",
|
||||||
"hatch==1.9.4",
|
"types-PyYAML",
|
||||||
]
|
]
|
||||||
docs = [
|
docs = [
|
||||||
"cogapp",
|
"cogapp",
|
||||||
|
@ -103,3 +106,6 @@ filterwarnings = [
|
||||||
"ignore:'crypt' is deprecated and slated for removal in Python 3.13:DeprecationWarning",
|
"ignore:'crypt' is deprecated and slated for removal in Python 3.13:DeprecationWarning",
|
||||||
"ignore:The 'app' shortcut is now deprecated:DeprecationWarning",
|
"ignore:The 'app' shortcut is now deprecated:DeprecationWarning",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
ignore_missing_imports = "True"
|
||||||
|
|
|
@ -10,7 +10,7 @@ os.environ["ARGOS_YAML_FILE"] = "tests/config.yaml"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def db() -> Session:
|
def db() -> Session: # type: ignore[misc]
|
||||||
from argos.server import models
|
from argos.server import models
|
||||||
|
|
||||||
app = _create_app()
|
app = _create_app()
|
||||||
|
@ -20,7 +20,7 @@ def db() -> Session:
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def app() -> FastAPI:
|
def app() -> FastAPI: # type: ignore[misc]
|
||||||
from argos.server import models
|
from argos.server import models
|
||||||
|
|
||||||
app = _create_app()
|
app = _create_app()
|
||||||
|
|
Loading…
Reference in a new issue