diff --git a/docs/app/reflex_docs/reflex_docs.py b/docs/app/reflex_docs/reflex_docs.py index 00dd00298bd..b891fe39518 100644 --- a/docs/app/reflex_docs/reflex_docs.py +++ b/docs/app/reflex_docs/reflex_docs.py @@ -6,6 +6,7 @@ import reflex as rx import reflex_enterprise as rxe from reflex_site_shared import styles +from reflex_site_shared.backend.status import monitor_checkly_status from reflex_site_shared.constants import REFLEX_ASSETS_CDN from reflex_site_shared.meta.meta import favicons_links, to_cdn_image_url from reflex_site_shared.telemetry import get_pixel_website_trackers @@ -47,6 +48,8 @@ ], ) +app.register_lifespan_task(monitor_checkly_status) + # XXX: The app is TOO BIG to build on Windows, so explicitly disallow it except for testing if sys.platform == "win32": if not os.environ.get("REFLEX_WEB_WINDOWS_OVERRIDE"): diff --git a/docs/app/reflex_docs/templates/docpage/docpage.py b/docs/app/reflex_docs/templates/docpage/docpage.py index bbd7625108f..b652d867d01 100644 --- a/docs/app/reflex_docs/templates/docpage/docpage.py +++ b/docs/app/reflex_docs/templates/docpage/docpage.py @@ -9,12 +9,14 @@ from reflex.components.radix.themes.base import LiteralAccentColor from reflex.experimental.client_state import ClientStateVar from reflex.utils.format import to_snake_case, to_title_case +from reflex_site_shared.backend.status import StatusState from reflex_site_shared.components.blocks.code import * from reflex_site_shared.components.blocks.demo import * from reflex_site_shared.components.blocks.headings import * from reflex_site_shared.components.blocks.typography import * from reflex_site_shared.components.icons import get_icon from reflex_site_shared.components.marketing_button import button as marketing_button +from reflex_site_shared.components.server_status import server_status from reflex_site_shared.route import Route, get_path from reflex_site_shared.styles.colors import c_color from reflex_site_shared.utils.docpage import right_sidebar_item_highlight @@ -284,9 +286,13 @@ def docpage_footer(path: str): menu_socials(), class_name="flex flex-row gap-6 justify-between items-end w-full", ), - rx.text( - f"Copyright © {datetime.now().year} Pynecone, Inc.", - class_name="font-small text-slate-9", + rx.el.div( + rx.text( + f"Copyright © {datetime.now().year} Pynecone, Inc.", + class_name="font-small text-slate-9", + ), + server_status(StatusState.status), + class_name="flex flex-row items-center gap-4 justify-between w-full", ), class_name="flex flex-col justify-between gap-10 py-6 lg:py-8 w-full", ), diff --git a/packages/reflex-site-shared/src/reflex_site_shared/backend/status.py b/packages/reflex-site-shared/src/reflex_site_shared/backend/status.py new file mode 100644 index 00000000000..e4e4caddc2a --- /dev/null +++ b/packages/reflex-site-shared/src/reflex_site_shared/backend/status.py @@ -0,0 +1,111 @@ +"""Checkly-backed service status state and polling utilities.""" + +import asyncio +import contextlib +from enum import StrEnum + +import httpx + +import reflex as rx +from reflex_site_shared.constants import ( + CHECKLY_ACCOUNT_ID, + CHECKLY_API_BASE_URL, + CHECKLY_API_KEY, + CHECKLY_CHECK_GROUP_ID, +) + + +class ServiceStatus(StrEnum): + """Supported service health states exposed in the UI.""" + + SUCCESS = "Success" + WARNING = "Warning" + CRITICAL = "Critical" + + +CURRENT_STATUS = ServiceStatus.SUCCESS.value + + +# Check status of each check in parallel +async def check_status(check_id: str) -> dict: + """Fetch status flags for a single Checkly check. + + Returns: + A dictionary with failure and degraded flags. + """ + status_url = f"{CHECKLY_API_BASE_URL}/check-statuses/{check_id}" + async with httpx.AsyncClient() as client: + status_response = await client.get( + status_url, + headers={ + "Authorization": f"Bearer {CHECKLY_API_KEY}", + "X-Checkly-Account": CHECKLY_ACCOUNT_ID, + }, + ) + if status_response.status_code == 200: + status_data = status_response.json() + return { + "has_failures": status_data.get("hasFailures", False), + "is_degraded": status_data.get("isDegraded", False), + } + + return {"has_failures": False, "is_degraded": False} + + +async def monitor_checkly_status() -> None: + """Continuously monitor Checkly check group status. + + Updates the global STATUS variable every 60 seconds. + - Critical: if any check has failures + - Warning: if no failures but some checks are degraded + - Success: all checks are healthy + + """ + if not all((CHECKLY_API_KEY, CHECKLY_ACCOUNT_ID, CHECKLY_CHECK_GROUP_ID)): + return + + headers = { + "Authorization": f"Bearer {CHECKLY_API_KEY}", + "X-Checkly-Account": CHECKLY_ACCOUNT_ID, + } + + try: + while True: + with contextlib.suppress(Exception): + global CURRENT_STATUS + + # Get checks in this group + checks_url = f"{CHECKLY_API_BASE_URL}/check-groups/{CHECKLY_CHECK_GROUP_ID}/checks" + async with httpx.AsyncClient(timeout=httpx.Timeout(30)) as client: + checks_response = await client.get(checks_url, headers=headers) + if checks_response.status_code == 200: + checks = checks_response.json() + + check_ids = [check.get("id") for check in checks if check.get("id")] + results = await asyncio.gather(*[ + check_status(check_id) for check_id in check_ids + ]) + + # Determine overall status + has_any_failures = any(r["has_failures"] for r in results) + has_any_degraded = any(r["is_degraded"] for r in results) + + if has_any_failures: + CURRENT_STATUS = ServiceStatus.CRITICAL.value + elif has_any_degraded: + CURRENT_STATUS = ServiceStatus.WARNING.value + else: + CURRENT_STATUS = ServiceStatus.SUCCESS.value + + await asyncio.sleep(60) + except asyncio.CancelledError: + pass + + +class StatusState(rx.State): + """Reflex state that exposes the current service status.""" + + @rx.var(interval=60) + def status(self) -> str: + """Return the current status value for the status pill.""" + return CURRENT_STATUS diff --git a/packages/reflex-site-shared/src/reflex_site_shared/components/icons.py b/packages/reflex-site-shared/src/reflex_site_shared/components/icons.py index 66488a21e3a..0c4312787f1 100644 --- a/packages/reflex-site-shared/src/reflex_site_shared/components/icons.py +++ b/packages/reflex-site-shared/src/reflex_site_shared/components/icons.py @@ -568,6 +568,10 @@ """ + +circle = """ +""" + ICONS = { # Socials "github": github, @@ -658,6 +662,7 @@ "moon_footer": moon_footer, "sun_footer": sun_footer, "computer_footer": computer_footer, + "circle": circle, } diff --git a/packages/reflex-site-shared/src/reflex_site_shared/components/server_status.py b/packages/reflex-site-shared/src/reflex_site_shared/components/server_status.py new file mode 100644 index 00000000000..60f16e44c8c --- /dev/null +++ b/packages/reflex-site-shared/src/reflex_site_shared/components/server_status.py @@ -0,0 +1,88 @@ +"""Server status badge component used in site footers.""" + +from typing import Literal + +import reflex as rx +from reflex_site_shared.components.icons import get_icon +from reflex_site_shared.constants import STATUS_WEB_URL + +StatusVariant = Literal["Success", "Warning", "Critical"] + +DEFAULT_CLASS_NAME = "inline-flex flex-row gap-1.5 items-center font-medium text-sm px-2.5 rounded-[10px] h-9 hover:bg-secondary-3 transition-bg" + +STATUS_TEXT_COLORS: dict[StatusVariant, str] = { + "Success": "text-success-9", + "Warning": "text-warning-11", + "Critical": "text-destructive-10", +} + + +STATUS_VARIANT_TEXT: dict[StatusVariant, str] = { + "Success": "All servers are operational", + "Warning": "Some servers are unavailable", + "Critical": "All servers are down", +} + +STATUS_ICON_COLORS: dict[StatusVariant, str] = { + "Success": "!text-success-8", + "Warning": "!text-warning-8", + "Critical": "!text-destructive-9", +} + + +def _status_icon(color: str) -> rx.Component: + """Create a fresh status icon component for each render branch. + + Returns: + A new circle icon component with the given color class. + """ + return get_icon("circle", class_name=color) + + +def server_status(status: StatusVariant | rx.Var[str]) -> rx.Component: + """Create a server status component. + + Args: + status: The status of the server. + + Returns: + A linked status badge that points to the public status page. + + """ + return rx.el.a( + rx.match( + status, + ( + "Success", + rx.el.div( + _status_icon(STATUS_ICON_COLORS["Success"]), + STATUS_VARIANT_TEXT["Success"], + class_name=f"{DEFAULT_CLASS_NAME} {STATUS_TEXT_COLORS['Success']}", + ), + ), + ( + "Warning", + rx.el.div( + _status_icon(STATUS_ICON_COLORS["Warning"]), + STATUS_VARIANT_TEXT["Warning"], + class_name=f"{DEFAULT_CLASS_NAME} {STATUS_TEXT_COLORS['Warning']}", + ), + ), + ( + "Critical", + rx.el.div( + _status_icon(STATUS_ICON_COLORS["Critical"]), + STATUS_VARIANT_TEXT["Critical"], + class_name=f"{DEFAULT_CLASS_NAME} {STATUS_TEXT_COLORS['Critical']}", + ), + ), + rx.el.div( + _status_icon(STATUS_ICON_COLORS["Success"]), + STATUS_VARIANT_TEXT["Success"], + class_name=f"{DEFAULT_CLASS_NAME} {STATUS_TEXT_COLORS['Success']}", + ), + ), + href=STATUS_WEB_URL, + target="_blank", + rel="noopener noreferrer", + ) diff --git a/packages/reflex-site-shared/src/reflex_site_shared/constants.py b/packages/reflex-site-shared/src/reflex_site_shared/constants.py index 9b57cb5db5e..66747418c10 100644 --- a/packages/reflex-site-shared/src/reflex_site_shared/constants.py +++ b/packages/reflex-site-shared/src/reflex_site_shared/constants.py @@ -21,6 +21,7 @@ TWITTER_URL = "https://twitter.com/getreflex" DISCORD_URL = "https://discord.gg/T5WSbC2YtQ" ROADMAP_URL = "https://github.com/reflex-dev/reflex/issues/2727" +STATUS_WEB_URL = "https://status.reflex.dev" REFLEX_URL = "https://reflex.dev/" REFLEX_DOMAIN_URL = "https://reflex.dev/" @@ -36,3 +37,9 @@ RECENT_BLOGS_API_URL: str = os.environ.get( "RECENT_BLOGS_API_URL", "https://reflex.dev/api/v1/recent-blogs" ) + + +CHECKLY_API_BASE_URL: str = "https://api.checklyhq.com/v1" +CHECKLY_ACCOUNT_ID = os.environ.get("CHECKLY_ACCOUNT_ID", "") +CHECKLY_API_KEY = os.environ.get("CHECKLY_API_KEY", "") +CHECKLY_CHECK_GROUP_ID = os.environ.get("CHECKLY_CHECK_GROUP_ID", "") diff --git a/packages/reflex-site-shared/src/reflex_site_shared/views/footer.py b/packages/reflex-site-shared/src/reflex_site_shared/views/footer.py index c63bbb39cf8..834361dbf51 100644 --- a/packages/reflex-site-shared/src/reflex_site_shared/views/footer.py +++ b/packages/reflex-site-shared/src/reflex_site_shared/views/footer.py @@ -8,7 +8,9 @@ import reflex as rx from reflex.style import color_mode, set_color_mode from reflex_site_shared.backend.signup import IndexState +from reflex_site_shared.backend.status import StatusState from reflex_site_shared.components.icons import get_icon +from reflex_site_shared.components.server_status import server_status from reflex_site_shared.constants import ( CHANGELOG_URL, DISCORD_URL, @@ -62,7 +64,7 @@ def logo() -> rx.Component: class_name="shrink-0 hidden dark:block", ), href="/", - class_name="block shrink-0 mr-[7rem] md:hidden xl:block", + class_name="block shrink-0 mr-[7rem] md:hidden xl:block h-fit", ) @@ -297,11 +299,15 @@ def footer_index(class_name: str = "", grid_class_name: str = "") -> rx.Componen class_name="flex flex-col max-lg:gap-6 lg:flex-row w-full", ), rx.el.div( - rx.el.span( - f"Copyright © {datetime.now().year} Pynecone, Inc.", - class_name="text-xs text-m-slate-7 dark:text-m-slate-6 font-medium", + server_status(StatusState.status), + rx.el.div( + rx.el.span( + f"Copyright © {datetime.now().year} Pynecone, Inc.", + class_name="text-xs text-m-slate-7 dark:text-m-slate-6 font-medium", + ), + menu_socials(), + class_name="flex flex-row items-center gap-6", ), - menu_socials(), rx.el.div( class_name="absolute -top-px -right-24 w-24 h-px bg-gradient-to-l from-transparent to-current text-m-slate-4 dark:text-m-slate-10 max-lg:hidden" ),