1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-17 18:32:35 +00:00

Deprecate old full health check endpoint, add new admin only health check and page with email tester.

This commit is contained in:
Nigel Gott 2023-05-15 11:24:15 +00:00
parent e43019454d
commit a8d93a3c6d
39 changed files with 848 additions and 28 deletions

View file

@ -95,7 +95,6 @@ DATABASE_NAME=baserow
# BASEROW_GROUP_STORAGE_USAGE_QUEUE=
# DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS=
# BASEROW_WAIT_INSTEAD_OF_409_CONFLICT_ERROR=
# BASEROW_FULL_HEALTHCHECKS=
# BASEROW_DISABLE_MODEL_CACHE=
# BASEROW_JOB_SOFT_TIME_LIMIT=
# BASEROW_JOB_CLEANUP_INTERVAL_MINUTES=

View file

@ -284,7 +284,7 @@ case "$1" in
fi
return 0
}
curlf "http://localhost:$BASEROW_BACKEND_PORT/_health/"
curlf "http://localhost:$BASEROW_BACKEND_PORT/api/_health/"
;;
bash)
exec /bin/bash -c "${@:2}"

View file

@ -0,0 +1,45 @@
from rest_framework import serializers
class FullHealthCheckSerializer(serializers.Serializer):
passing = serializers.BooleanField(
read_only=True,
help_text="False if any of the critical service health checks are failing, "
"true otherwise.",
)
checks = serializers.DictField(
read_only=True,
child=serializers.CharField(),
help_text="An object keyed by the name of the "
"health check and the value being "
"the result.",
)
class EmailTesterResponseSerializer(serializers.Serializer):
succeeded = serializers.BooleanField(
help_text="Whether or not the test email was sent successfully.", required=True
)
error_stack = serializers.CharField(
help_text="The full stack trace and error message if the test email failed.",
required=False,
allow_null=True,
allow_blank=True,
)
error_type = serializers.CharField(
help_text="The type of error that occurred if the test email failed.",
required=False,
allow_null=True,
allow_blank=True,
)
error = serializers.CharField(
help_text="A short message describing the error that occured if the test "
"email failed",
required=False,
allow_null=True,
allow_blank=True,
)
class EmailTesterRequestSerializer(serializers.Serializer):
target_email = serializers.EmailField(required=True)

View file

@ -0,0 +1,17 @@
from django.http import HttpResponse
from django.urls import re_path
from baserow.api.health.views import EmailTesterView, FullHealthCheckView
app_name = "baserow.api.health"
def public_health_check(request):
return HttpResponse("OK")
urlpatterns = [
re_path(r"full/$", FullHealthCheckView.as_view(), name="full_health_check"),
re_path(r"email/$", EmailTesterView.as_view(), name="email_tester"),
re_path("^$", public_health_check, name="public_health_check"),
]

View file

@ -0,0 +1,75 @@
import traceback
from typing import Any, Dict
from drf_spectacular.utils import extend_schema
from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from baserow.api.decorators import validate_body
from baserow.api.health.serializers import (
EmailTesterRequestSerializer,
EmailTesterResponseSerializer,
FullHealthCheckSerializer,
)
from baserow.api.schemas import get_error_schema
from baserow.core.health.handler import HealthCheckHandler
class FullHealthCheckView(APIView):
permission_classes = (IsAdminUser,)
@extend_schema(
tags=["Health"],
request=None,
operation_id="full_health_check",
description="Runs a full health check testing as many services and systems "
"as possible. These health checks can be expensive operations such as writing "
"files to storage etc.",
responses={
200: FullHealthCheckSerializer,
},
)
def get(self, request: Request) -> Response:
result = HealthCheckHandler.run_all_checks()
return Response(
FullHealthCheckSerializer(
{"checks": result.checks, "passing": result.passing}
).data
)
class EmailTesterView(APIView):
permission_classes = (IsAdminUser,)
@extend_schema(
tags=["Health"],
request=EmailTesterRequestSerializer,
operation_id="email_tester",
description="Sends a test email to the provided email address. Useful for "
"testing Baserow's email configuration as errors are clearly "
"returned.",
responses={
200: EmailTesterResponseSerializer,
400: get_error_schema(["ERROR_REQUEST_BODY_VALIDATION"]),
},
)
@validate_body(EmailTesterRequestSerializer, return_validated=True)
def post(self, request: Request, data: Dict[str, Any]) -> Response:
target_email = data["target_email"]
try:
HealthCheckHandler.send_test_email(target_email)
return Response(EmailTesterResponseSerializer({"succeeded": True}).data)
except Exception as e:
full = traceback.format_exc()
return Response(
EmailTesterResponseSerializer(
{
"succeeded": False,
"error_type": e.__class__.__name__,
"error_stack": full,
"error": str(e),
}
).data
)

View file

@ -1,4 +1,3 @@
from django.http import HttpResponse
from django.urls import include, path
from drf_spectacular.views import SpectacularRedocView
@ -11,6 +10,7 @@ from baserow.core.registries import application_type_registry, plugin_registry
from .applications import urls as application_urls
from .auth_provider import urls as auth_provider_urls
from .health import urls as health_urls
from .jobs import urls as jobs_urls
from .settings import urls as settings_urls
from .snapshots import urls as snapshots_urls
@ -24,10 +24,6 @@ from .workspaces import urls as workspace_urls
app_name = "baserow.api"
def public_health_check(request):
return HttpResponse("OK")
urlpatterns = (
[
path("schema.json", CachedSpectacularJSONAPIView.as_view(), name="json_schema"),
@ -46,7 +42,7 @@ urlpatterns = (
path("trash/", include(trash_urls, namespace="trash")),
path("jobs/", include(jobs_urls, namespace="jobs")),
path("snapshots/", include(snapshots_urls, namespace="snapshots")),
path("_health/", public_health_check, name="public_health_check"),
path("_health/", include(health_urls, namespace="health")),
# GroupDeprecation
path("groups/", include(group_compat_urls, namespace="groups")),
path(

View file

@ -81,6 +81,8 @@ INSTALLED_APPS = [
"health_check.cache",
"health_check.contrib.migrations",
"health_check.contrib.redis",
"health_check.contrib.celery_ping",
"health_check.contrib.psutil",
"baserow.core",
"baserow.api",
"baserow.ws",
@ -122,10 +124,6 @@ AUTO_INDEX_LOCK_EXPIRY = os.getenv("BASEROW_AUTO_INDEX_LOCK_EXPIRY", 60 * 2)
if "builder" in FEATURE_FLAGS:
INSTALLED_APPS.append("baserow.contrib.builder")
BASEROW_FULL_HEALTHCHECKS = os.getenv("BASEROW_FULL_HEALTHCHECKS", None)
if BASEROW_FULL_HEALTHCHECKS is not None:
INSTALLED_APPS += ["health_check.storage", "health_check.contrib.psutil"]
ADDITIONAL_APPS = os.getenv("ADDITIONAL_APPS", "").split(",")
if ADDITIONAL_APPS is not None:
INSTALLED_APPS += [app.strip() for app in ADDITIONAL_APPS if app.strip() != ""]

View file

@ -68,3 +68,5 @@ CELERY_BROKER_POOL_LIMIT = min(
CELERY_REDIS_MAX_CONNECTIONS = min(
4 * int(os.getenv("BASEROW_AMOUNT_OF_WORKERS", "1")), 10 # noqa: F405
)
HEROKU_ENABLED = True

View file

@ -1,13 +1,26 @@
from django.conf import settings
from django.conf.urls.static import static
from django.http import HttpResponse
from django.urls import include, path, re_path
from baserow.core.registries import plugin_registry
def old_deprecated_health_check(request):
"""
This was an old health check that ran expensive and potentially dangerous when
publicly exposed checks. These checks have been moved into the new, admin only,
/api/_health/full/ check endpoint and this one is left for backwards compatability
only.
"""
return HttpResponse("OK")
urlpatterns = (
[
re_path(r"^api/", include("baserow.api.urls", namespace="api")),
re_path(r"^_health/", include("health_check.urls")),
path("_health/", old_deprecated_health_check, name="root_health_check"),
]
+ plugin_registry.urls
+ static(settings.MEDIA_URL_PATH, document_root=settings.MEDIA_ROOT)

View file

@ -246,11 +246,34 @@ class CoreConfig(AppConfig):
auth_provider_type_registry.register(PasswordAuthProviderType())
self._setup_health_checks()
# Clear the key after migration so we will trigger a new template sync.
post_migrate.connect(start_sync_templates_task_after_migrate, sender=self)
# Create all operations from registry
post_migrate.connect(sync_operations_after_migrate, sender=self)
def _setup_health_checks(self):
from health_check.contrib.s3boto_storage.backends import (
S3BotoStorageHealthCheck,
)
from health_check.plugins import plugin_dir
from health_check.storage.backends import DefaultFileStorageHealthCheck
from .health.custom_health_checks import (
DebugModeHealthCheck,
HerokuExternalFileStorageConfiguredHealthCheck,
)
plugin_dir.register(DebugModeHealthCheck)
if getattr(settings, "HEROKU_ENABLED", False):
plugin_dir.register(HerokuExternalFileStorageConfiguredHealthCheck)
if settings.DEFAULT_FILE_STORAGE == "storages.backends.s3boto3.S3Boto3Storage":
plugin_dir.register(S3BotoStorageHealthCheck)
else:
plugin_dir.register(DefaultFileStorageHealthCheck)
# noinspection PyPep8Naming
def start_sync_templates_task_after_migrate(sender, **kwargs):

View file

@ -0,0 +1,35 @@
from django.conf import settings
from health_check.backends import BaseHealthCheckBackend
from health_check.exceptions import ServiceWarning
class DebugModeHealthCheck(BaseHealthCheckBackend):
critical_service = False
def check_status(self):
if settings.DEBUG:
raise ServiceWarning(
"DEBUG is enabled, this is insecure for production or public usage."
)
def identifier(self):
return self.__class__.__name__
class HerokuExternalFileStorageConfiguredHealthCheck(BaseHealthCheckBackend):
critical_service = False
def check_status(self):
if (
settings.DEFAULT_FILE_STORAGE
== "baserow.core.storage.OverwriteFileSystemStorage"
):
raise ServiceWarning(
"Any uploaded files will be lost on dyno restart because you have "
"not configured an external file storage service. Please set "
"AWS_ACCESS_KEY_ID and related env vars to prevent file loss."
)
def identifier(self):
return self.__class__.__name__

View file

@ -0,0 +1,84 @@
import copy
from typing import Dict, NamedTuple
from django.conf import settings
from django.core import mail
from django.core.mail import EmailMessage
from health_check.plugins import plugin_dir
class HealthCheckResult(NamedTuple):
checks: Dict[str, str]
passing: bool
class HealthCheckHandler:
@classmethod
def get_plugins(cls):
return sorted(
(
plugin_class(**copy.deepcopy(options))
for plugin_class, options in plugin_dir._registry
),
key=lambda plugin: plugin.identifier(),
)
@classmethod
def run_all_checks(cls) -> HealthCheckResult:
"""
The health_check library provides its own template and view. However, it has
bugs, runs health checks in threads which causes further problems and does
not fit into our DRF based authentication. We just use this library
for the nice suite of health check plugins it provides, and use its python
API to run those in a simpler and safer fashion in this method.
"""
checks = {}
passing = True
for plugin in cls.get_plugins():
# The plugin checks can fail and raise but we always want to catch
# to report back that they failed.
# noinspection PyBroadException
try:
plugin.run_check()
except Exception: # nosec
pass
checks[str(plugin.identifier())] = str(plugin.pretty_status())
if plugin.critical_service and not plugin.status:
passing = False
return HealthCheckResult(checks, passing)
@classmethod
def send_test_email(cls, target_email: str):
"""
This method sends a test email synchronously and will raise any exceptions
that occur.
To do this it forces the backend not to be the celery one, which triggers a
celery task when `email.send` is called, but instead the normal backend that
celery itself would have been using within that task.
Sending via a celery task makes this test pointless as we can't get the result
back from the celery task. So this way we force the email to be sent
synchronously, so we can immediately see any errors and respond with
them back to the user.
:param target_email: The email address to send the test email to.
:raises: Will attempt to send the email and will raise any Exceptions that occur
if the sending fails.
"""
with mail.get_connection(
fail_silently=False, backend=settings.CELERY_EMAIL_BACKEND
) as connection:
email = EmailMessage(
"Test email from Baserow",
"This is a test email sent by the email tester in Baserow",
settings.FROM_EMAIL,
[target_email],
connection=connection,
)
email.send(fail_silently=False)

View file

@ -0,0 +1,66 @@
from unittest.mock import patch
from django.urls import reverse
import pytest
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN
@pytest.mark.django_db
def test_anonymous_user_cant_trigger_test_email(data_fixture, api_client):
response = api_client.post(
reverse("api:health:email_tester"),
{"target_email": "test@test.com"},
)
assert response.status_code == HTTP_401_UNAUTHORIZED
@pytest.mark.django_db
def test_non_staff_user_cant_trigger_test_email(data_fixture, api_client):
user, token = data_fixture.create_user_and_token()
response = api_client.get(
reverse("api:health:email_tester"),
{"target_email": "test@test.com"},
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_403_FORBIDDEN
@pytest.mark.django_db
def test_staff_user_can_trigger_test_email(data_fixture, api_client):
user, token = data_fixture.create_user_and_token(is_staff=True)
response = api_client.post(
reverse("api:health:email_tester"),
{"target_email": "test@test.com"},
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
assert response.json() == {
"error": None,
"error_stack": None,
"error_type": None,
"succeeded": True,
}
@pytest.mark.django_db
@patch("django.core.mail.get_connection")
def test_staff_user_can_trigger_test_email_and_see_error_if_fails(
patched_get_connection, data_fixture, api_client
):
user, token = data_fixture.create_user_and_token(is_staff=True)
patched_get_connection.side_effect = Exception("Failed")
response = api_client.post(
reverse("api:health:email_tester"),
{"target_email": "test@test.com"},
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
json = response.json()
assert not json["succeeded"]
assert json["error"] == "Failed"
assert json["error_type"] == "Exception"
assert len(json["error_stack"]) > 1

View file

@ -0,0 +1,93 @@
from django.urls import reverse
import pytest
from health_check.backends import BaseHealthCheckBackend
from health_check.db.backends import DatabaseBackend
from health_check.exceptions import ServiceUnavailable
from health_check.plugins import plugin_dir
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN
class AlwaysFailingHealthCheck(BaseHealthCheckBackend):
def check_status(self):
raise ServiceUnavailable("Error")
@pytest.fixture(autouse=True)
def reset_health_checks_to_expected():
plugin_dir.reset()
plugin_dir.register(DatabaseBackend)
@pytest.mark.django_db
def test_anonymous_user_cant_get_full_health_checks(data_fixture, api_client):
response = api_client.get(
reverse("api:health:full_health_check"),
content_type="application/json",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
@pytest.mark.django_db
def test_non_staff_user_cant_get_full_health_checks(data_fixture, api_client):
user, token = data_fixture.create_user_and_token()
response = api_client.get(
reverse("api:health:full_health_check"),
content_type="application/json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_403_FORBIDDEN
@pytest.mark.django_db
def test_staff_user_can_get_full_health_checks(data_fixture, api_client):
user, token = data_fixture.create_user_and_token(is_staff=True)
response = api_client.get(
reverse("api:health:full_health_check"),
content_type="application/json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
@pytest.mark.django_db
def test_full_health_check_endpoint_returns_dict_of_checks_vs_status_with_200_status(
data_fixture, api_client
):
user, token = data_fixture.create_user_and_token(is_staff=True)
response = api_client.get(
reverse("api:health:full_health_check"),
content_type="application/json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
assert response.json() == {
"passing": True,
"checks": {
"DatabaseBackend": "working",
},
}
@pytest.mark.django_db
def test_passing_is_false_when_one_critical_service_fails(data_fixture, api_client):
user, token = data_fixture.create_user_and_token(is_staff=True)
plugin_dir.register(AlwaysFailingHealthCheck)
response = api_client.get(
reverse("api:health:full_health_check"),
content_type="application/json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
assert response.json() == {
"checks": {
"AlwaysFailingHealthCheck": "unavailable: Error",
"DatabaseBackend": "working",
},
"passing": False,
}

View file

@ -0,0 +1,33 @@
from django.test.utils import override_settings
import pytest
from health_check.exceptions import ServiceWarning
from baserow.core.health.custom_health_checks import (
DebugModeHealthCheck,
HerokuExternalFileStorageConfiguredHealthCheck,
)
@override_settings(DEBUG=True)
def test_debug_health_check_raises_when_debug_true():
with pytest.raises(ServiceWarning):
DebugModeHealthCheck().check_status()
@override_settings(DEBUG=False)
def test_debug_health_check_does_not_raise_when_debug_false():
DebugModeHealthCheck().check_status()
@override_settings(
DEFAULT_FILE_STORAGE="baserow.core.storage.OverwriteFileSystemStorage"
)
def test_heroku_health_check_raises_when_default_storage_set():
with pytest.raises(ServiceWarning):
HerokuExternalFileStorageConfiguredHealthCheck().check_status()
@override_settings(DEFAULT_FILE_STORAGE="storages.backends.s3boto3.S3Boto3Storage")
def test_heroku_health_check_doesnt_raise_when_boto_set():
HerokuExternalFileStorageConfiguredHealthCheck().check_status()

View file

@ -0,0 +1,7 @@
{
"type": "breaking_change",
"message": "Remove BASEROW_FULL_HEALTHCHECKS env var as private _health check endpoint which this env var affected has been simplified, depricated and replaced with the new /api/_health/full/ endpoint.",
"issue_number": " ",
"bullet_points": [],
"created_at": "2023-04-14"
}

View file

@ -0,0 +1,7 @@
{
"type": "breaking_change",
"message": "Depricate private _health/ check endpoint and simplify the check it performs for security reasons.",
"issue_number": null,
"bullet_points": [],
"created_at": "2023-04-14"
}

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "Add a new admin health check page for seeing the status of your Baserow server.",
"issue_number": 521,
"bullet_points": [],
"created_at": "2023-04-14"
}

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "Add email tester to health check page for easily debugging Baserow email sending issues.",
"issue_number": 521,
"bullet_points": [],
"created_at": "2023-04-14"
}

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "Add new /api/_health/full/ JSON API healthcheck endpoint for admins only to run a full set of health checks against Baserow.",
"issue_number": 521,
"bullet_points": [],
"created_at": "2023-04-14"
}

View file

@ -14,7 +14,7 @@ baserow_ready() {
return 0
}
if curlf "http://localhost:3000/_health/" && curlf "http://localhost:8000/_health/"; then
if curlf "http://localhost:3000/_health/" && curlf "http://localhost:8000/api/_health/"; then
return 0
else
return 1

View file

@ -92,7 +92,6 @@ x-backend-variables: &backend-variables
BASEROW_GROUP_STORAGE_USAGE_QUEUE:
DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS:
BASEROW_WAIT_INSTEAD_OF_409_CONFLICT_ERROR:
BASEROW_FULL_HEALTHCHECKS:
BASEROW_DISABLE_MODEL_CACHE:
BASEROW_PLUGIN_DIR:
BASEROW_JOB_EXPIRATION_TIME_LIMIT:

View file

@ -111,7 +111,6 @@ x-backend-variables: &backend-variables
BASEROW_GROUP_STORAGE_USAGE_QUEUE:
DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS:
BASEROW_WAIT_INSTEAD_OF_409_CONFLICT_ERROR:
BASEROW_FULL_HEALTHCHECKS:
BASEROW_DISABLE_MODEL_CACHE:
BASEROW_PLUGIN_DIR:
BASEROW_JOB_EXPIRATION_TIME_LIMIT:

View file

@ -108,7 +108,6 @@ x-backend-variables: &backend-variables
BASEROW_GROUP_STORAGE_USAGE_QUEUE:
DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS:
BASEROW_WAIT_INSTEAD_OF_409_CONFLICT_ERROR:
BASEROW_FULL_HEALTHCHECKS:
BASEROW_DISABLE_MODEL_CACHE:
BASEROW_PLUGIN_DIR:
BASEROW_JOB_EXPIRATION_TIME_LIMIT:

View file

@ -43,7 +43,7 @@ The src directory contains the full source code of the Baserow backend module.
is included by the root url config under the namespace `api`.
* `config`: is a module that contains base settings and some settings that are for
specific environments. It also contains the root url config that includes the api
under the namespace `api` and adds a `_health` route for health checking. There is
under the namespace `api`. There is
also a wsgi.py file which can be used to expose the applications.
* `contrib`: contains extra apps that can be installed. For now it only contains the
backend part of the database plugin. This app is installed by default, but it is

View file

@ -117,7 +117,6 @@ The installation methods referred to in the variable descriptions are:
| HOURS\_UNTIL\_TRASH\_PERMANENTLY\_DELETED | Items from the trash will be permanently deleted after this number of hours. | |
| DISABLE\_ANONYMOUS\_PUBLIC\_VIEW\_WS\_CONNECTIONS | When sharing views publicly a websocket connection is opened to provide realtime updates to viewers of the public link. To disable this set any non empty value. When disabled publicly shared links will need to be refreshed to see any updates to the view. | |
| BASEROW\_WAIT\_INSTEAD\_OF\_409\_CONFLICT\_ERROR | When updating or creating various resources in Baserow if another concurrent operation is ongoing (like a snapshot, duplication, import etc) which would be affected by your modification a 409 HTTP error will be returned. If you instead would prefer Baserow to not return a 409 and just block waiting until the operation finishes and then to perform the requested operation set this flag to any non-empty value. | |
| BASEROW\_FULL\_HEALTHCHECKS | When set to any non empty value will additionally check in the backend's healthcheck at /_health/ if storage can be written to (causes lots of small filesystem writes) and a more general check if enough disk and memory is available. | |
| BASEROW\_JOB\_CLEANUP\_INTERVAL\_MINUTES | How often the job cleanup task will run. | 5 |
| BASEROW\_JOB\_EXPIRATION\_TIME\_LIMIT | How long before a Baserow job will be kept before being cleaned up. | 30 * 24 * 60 (24 days) |
| BASEROW\_JOB\_SOFT\_TIME\_LIMIT | The number of seconds a Baserow job can run before being terminated. | 1800 |

View file

@ -63,12 +63,6 @@ metadata:
nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
nginx.ingress.kubernetes.io/proxy-body-size: 20m
# Prevent access to the full internal healthcheck
nginx.ingress.kubernetes.io/server-snippet: |
location ~* "^/_health" {
deny all;
return 403;
}
kubernetes.io/ingress.class: nginx
# removed ssl settings, add in your own desired ones
spec:

View file

@ -24,7 +24,7 @@ baserow_ready() {
return 22
}
if curlf "${PUBLIC_WEB_FRONTEND_URL:-http://web-frontend:3000}/_health/" && curlf "${PUBLIC_BACKEND_URL:-http://backend:8000}/_health/" && templates_ready; then
if curlf "${PUBLIC_WEB_FRONTEND_URL:-http://web-frontend:3000}/_health/" && curlf "${PUBLIC_BACKEND_URL:-http://backend:8000}/api/_health/" && templates_ready; then
return 0
else
return 1

View file

@ -35,7 +35,8 @@
"copyToClipboard": "Copy to clipboard"
},
"adminType": {
"settings": "Settings"
"settings": "Settings",
"health": "Health"
},
"applicationType": {
"database": "Database",
@ -404,5 +405,17 @@
"urlCheck": {
"invalidUrlEnvVarTitle": "Invalid {name}",
"invalidUrlEnvVarDescription": "The {name} environment variable has been set to an invalid value. Your site admin must change {name} to a valid URL starting with http:// or https:// and then restart Baserow to fix this error."
},
"health": {
"title": "Baserow health checks",
"description": "These checks show the current status of your Baserow installation."
},
"emailTester": {
"title": "Email tester",
"targetEmailLabel": "Target Email",
"invalidTargetEmail": "Invalid email address",
"submit": "Send test email",
"success": "Successfully sent test email",
"configLink": "Email Configuration Help"
}
}

View file

@ -99,3 +99,26 @@ export class SettingsAdminType extends AdminType {
return 9999
}
}
export class HealthCheckAdminType extends AdminType {
static getType() {
return 'health'
}
getIconClass() {
return 'medkit'
}
getName() {
const { i18n } = this.app
return i18n.t('adminType.health')
}
getRouteName() {
return 'admin-health'
}
getOrder() {
return 10000
}
}

View file

@ -0,0 +1,63 @@
.admin-health {
padding: 30px;
}
.admin-health__group {
max-width: 30vw;
min-width: 160px;
&:not(:last-child) {
padding-bottom: 30px;
margin-bottom: 30px;
border-bottom: solid 1px $color-neutral-200;
}
}
.admin-health__description {
font-size: 13px;
color: $color-neutral-600;
line-height: 160%;
margin-bottom: 20px;
}
.admin-health__check-item {
display: flex;
&:not(:last-child) {
margin-bottom: 16px;
}
}
.admin-health__check-item-label {
flex: 0 0 50%;
margin-right: 40px;
}
.admin-health__check-item-name {
font-size: 14px;
font-weight: 700;
margin-bottom: 12px;
}
.admin-health__check-item-description {
font-size: 13px;
color: $color-warning-600;
line-height: 160%;
}
.admin-health__icon {
min-width: 0;
.warning {
max-width: 500px;
line-height: 18px;
}
}
.admin-health__icon--success {
color: $color-success-500;
}
.admin-health__icon--fail {
color: $color-error-500;
}

View file

@ -89,6 +89,7 @@
@import 'separator';
@import 'quote';
@import 'admin_settings';
@import 'admin_health';
@import 'templates';
@import 'paginator';
@import 'sortable';
@ -115,3 +116,4 @@
@import 'badge';
@import 'builder/all';
@import 'expandable_card';
@import 'email_tester';

View file

@ -0,0 +1,5 @@
.email-tester__full-stack {
overflow: auto;
margin-bottom: 0;
padding-bottom: 20px;
}

View file

@ -0,0 +1,115 @@
<template>
<div>
<h2>
{{ $t('emailTester.title') }}
<a
href="https://baserow.io/docs/installation%2Fconfiguration#email-configuration"
target="_blank"
><i class="fas fa-question-circle"
/></a>
</h2>
<Error :error="error" />
<div v-if="testResult.succeeded != null">
<Alert
v-if="!testResult.succeeded"
type="error"
:title="testResult.error_type"
>
<span>{{ testResult.error }}</span>
<pre class="email-tester__full-stack">{{ trimmedFullStack }}</pre>
</Alert>
<Alert v-else type="success" :title="$t('emailTester.success')"> </Alert>
</div>
<form @submit.prevent="submit">
<FormElement :error="fieldHasErrors('targetEmail')" class="control">
<label class="control__label">{{
$t('emailTester.targetEmailLabel')
}}</label>
<div class="control__elements">
<input
ref="name"
v-model="values.targetEmail"
:class="{ 'input--error': fieldHasErrors('targetEmail') }"
type="text"
class="input input--large"
:disabled="loading"
@blur="$v.values.targetEmail.$touch()"
/>
<div v-if="fieldHasErrors('targetEmail')" class="error">
{{ $t('emailTester.invalidTargetEmail') }}
</div>
</div>
</FormElement>
<button
class="button button--primary"
:class="{ 'button--loading': loading }"
:disabled="loading || $v.$invalid"
>
{{ $t('emailTester.submit') }}
</button>
</form>
</div>
</template>
<script>
import error from '@baserow/modules/core/mixins/error'
import HealthService from '@baserow/modules/core/services/health'
import form from '@baserow/modules/core/mixins/form'
import { required, email } from 'vuelidate/lib/validators'
import { mapGetters } from 'vuex'
export default {
name: 'EmailerTester',
mixins: [error, form],
data() {
return {
loading: false,
values: {
targetEmail: 'test@example.com',
},
testResult: {
succeeded: null,
error_type: null,
error: null,
error_stack: null,
},
}
},
computed: {
...mapGetters({ username: 'auth/getUsername' }),
trimmedFullStack() {
return this.testResult?.error_stack?.trim()
},
},
mounted() {
if (this.username) {
this.values.targetEmail = this.username
}
},
methods: {
async submit() {
this.loading = true
this.testResult = {}
this.hideError()
try {
const { data } = await HealthService(this.$client).testEmail(
this.values.targetEmail
)
this.testResult = data
} catch (e) {
this.handleError(e, 'health')
this.testResult = {}
}
this.loading = false
},
},
validations: {
values: {
targetEmail: { required, email },
},
},
}
</script>

View file

@ -0,0 +1,74 @@
<template>
<div class="layout__col-2-scroll">
<div class="admin-health">
<h1>
{{ $t('health.title') }}
</h1>
<div class="admin-health__group">
<div class="admin-health__description">
{{ $t('health.description') }}
</div>
<div>
<div
v-for="(status, checkName) in healthChecks"
:key="status"
class="admin-health__check-item"
>
<div class="admin-health__check-item-label">
<div class="admin-health__check-item-name">
{{ camelCaseToSpaceSeparated(checkName) }}
</div>
</div>
<div
class="admin-health__icon"
:class="status !== 'working' ? 'warning' : ''"
>
<i
class="fas"
:class="
status === 'working'
? 'fa-check admin-health__icon--success'
: 'fa-times admin-health__icon--fail'
"
></i>
<div
v-if="status !== 'working'"
class="admin-health__check-item-description"
>
{{ status }}
</div>
</div>
</div>
</div>
</div>
<div class="admin-health__group">
<EmailerTester></EmailerTester>
</div>
</div>
</div>
</template>
<script>
import HealthService from '@baserow/modules/core/services/health'
import EmailerTester from '@baserow/modules/core/components/health/EmailTester.vue'
export default {
components: { EmailerTester },
layout: 'app',
middleware: 'staff',
async asyncData({ app }) {
const { data } = await HealthService(app.$client).getAll()
return { healthChecks: data.checks }
},
methods: {
camelCaseToSpaceSeparated(camelCaseString) {
if (!camelCaseString) {
return 'unknown'
} else {
camelCaseString = camelCaseString.toString()
}
return camelCaseString.replace(/([A-Z])/g, ' $1')
},
},
}
</script>

View file

@ -16,7 +16,10 @@ import {
UploadFileUserFileUploadType,
UploadViaURLUserFileUploadType,
} from '@baserow/modules/core/userFileUploadTypes'
import { SettingsAdminType } from '@baserow/modules/core/adminTypes'
import {
HealthCheckAdminType,
SettingsAdminType,
} from '@baserow/modules/core/adminTypes'
import {
BasicPermissionManagerType,
@ -103,6 +106,7 @@ export default (context, inject) => {
new UploadViaURLUserFileUploadType(context)
)
registry.register('admin', new SettingsAdminType(context))
registry.register('admin', new HealthCheckAdminType(context))
inject('registry', registry)
store.registerModule('settings', settingsStore)

View file

@ -51,6 +51,11 @@ export const routes = [
path: '/admin/settings',
component: path.resolve(__dirname, 'pages/admin/settings.vue'),
},
{
name: 'admin-health',
path: '/admin/health',
component: path.resolve(__dirname, 'pages/admin/health.vue'),
},
{
name: 'style-guide',
path: '/style-guide',

View file

@ -0,0 +1,12 @@
export default (client) => {
return {
getAll() {
return client.get('/_health/full/')
},
testEmail(targetEmail) {
return client.post('/_health/email/', {
target_email: targetEmail,
})
},
}
}