diff --git a/.github/labeler.yml b/.github/labeler.yml index cbc780e8a0..984ea7776c 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -48,7 +48,6 @@ app:alert_troubleshooting: - 'app/sep/apps/alert_troubleshooting/**' - 'frontend/packages/apps/alert_troubleshooting/**' - 'tests/app/sep/apps/alert_troubleshooting/**' - - 'templates/alert_troubleshooting/**' - 'frontend/packages/e2e/tests/alert-troubleshooting*.spec.ts' app:alerts: - any: @@ -57,7 +56,6 @@ app:alerts: - 'app/sep/apps/alerts/**' - 'frontend/packages/apps/alerts/**' - 'tests/app/sep/apps/alerts/**' - - 'templates/alerts/**' - 'frontend/packages/e2e/tests/alerts*.spec.ts' app:alters: - any: @@ -66,7 +64,6 @@ app:alters: - 'app/sep/apps/alters/**' - 'frontend/packages/apps/alters/**' - 'tests/app/sep/apps/alters/**' - - 'templates/alters/**' - 'frontend/packages/e2e/tests/alters*.spec.ts' app:archives: - any: @@ -74,7 +71,6 @@ app:archives: - any-glob-to-any-file: - 'app/sep/apps/archives/**' - 'tests/app/sep/apps/archives/**' - - 'templates/archiver/**' - 'frontend/packages/e2e/tests/archives*.spec.ts' app:atw: - any: @@ -91,7 +87,6 @@ app:backup_mongo: - 'app/sep/apps/backup_mongo/**' - 'frontend/packages/apps/backup_mongo/**' - 'tests/app/sep/apps/backup_mongo/**' - - 'templates/backup_mongo/**' - 'frontend/packages/e2e/tests/backup_mongo*.spec.ts' app:backup_pg: - any: @@ -99,7 +94,6 @@ app:backup_pg: - any-glob-to-any-file: - 'app/sep/apps/backup_pg/**' - 'tests/app/sep/apps/backup_pg/**' - - 'templates/backup_pg/**' - 'frontend/packages/e2e/tests/backup_pg*.spec.ts' app:checksums: - any: @@ -107,7 +101,6 @@ app:checksums: - any-glob-to-any-file: - 'app/sep/apps/checksums/**' - 'tests/app/sep/apps/checksums/**' - - 'templates/checksums/**' app:dipper: - any: - changed-files: @@ -115,7 +108,6 @@ app:dipper: - 'app/sep/apps/dipper/**' - 'frontend/packages/apps/dipper/**' - 'tests/app/sep/apps/dipper/**' - - 'templates/dipper/**' app:inventory: - any: - changed-files: @@ -123,7 +115,6 @@ app:inventory: - 'app/sep/apps/inventory/**' - 'frontend/packages/apps/inventory/**' - 'tests/app/sep/apps/inventory/**' - - 'templates/inventory/**' - 'frontend/packages/e2e/tests/inventory*.spec.ts' app:mysql_backups: - any: @@ -131,7 +122,6 @@ app:mysql_backups: - any-glob-to-any-file: - 'app/sep/apps/mysql_backups/**' - 'tests/app/sep/apps/mysql_backups/**' - - 'templates/mysql_backups/**' - 'frontend/packages/e2e/tests/mysql-backups*.spec.ts' app:report: - any: @@ -140,7 +130,6 @@ app:report: - 'app/sep/apps/report/**' - 'frontend/packages/apps/report/**' - 'tests/app/sep/apps/report/**' - - 'templates/report/**' - 'frontend/packages/e2e/tests/report*.spec.ts' app:snippets: - any: @@ -149,7 +138,6 @@ app:snippets: - 'app/sep/apps/snippets/**' - 'frontend/packages/apps/snippets/**' - 'tests/app/sep/apps/snippets/**' - - 'templates/snippets/**' - 'frontend/packages/e2e/tests/snippets*.spec.ts' app:tasks: - any: @@ -158,7 +146,6 @@ app:tasks: - 'app/sep/apps/tasks/**' - 'frontend/packages/apps/tasks/**' - 'tests/app/sep/apps/tasks/**' - - 'templates/tasks/**' - 'frontend/packages/e2e/tests/tasks*.spec.ts' app:topology: - any: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 896156b4af..496b665f10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,8 +96,6 @@ jobs: - 'pyproject.toml' - 'poetry.lock' - 'snippets/**' - - 'static/**' - - 'templates/**' - 'entrypoint.sh' - 'entrypoint_celery.sh' - 'Makefile' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ba40cdb12c..240fb2c1a0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,38 +7,15 @@ repos: hooks: - id: ruff - id: ruff-format - - repo: https://github.com/djlint/djLint - rev: 9112cb64851c95a7802358af285d21ad8b7f6437 # v1.36.4 - hooks: - - id: djlint - types_or: [jinja] - - id: djlint-reformat - types_or: [jinja] - repo: https://github.com/google/addlicense rev: b289835c0741ba2fb92dd5f61af070e79c69a55e # v1.2.0 hooks: - id: addlicense args: ["-f", ".license_header", "-v"] types_or: [python, javascript, ts, tsx, css, jinja] - exclude: ^(static/(js|css)/vendor/|frontend/(node_modules|.*/dist|packages/[^/]+/src/generated)/) + exclude: ^frontend/(node_modules|.*/dist|packages/[^/]+/src/generated)/ - repo: local hooks: - - id: js-beautify - name: JS Beautify - description: "Run js-beautify (from jsbeautifier) on .js files" - entry: js-beautify --end-with-newline --replace - language: python - additional_dependencies: ["jsbeautifier==1.15.1"] - files: ^(?!.*\.min\.js$)(?!static/(js|css)/vendor/).*\.js$ - exclude: ^frontend/ - - id: css-beautify - name: CSS Beautify - description: "Run css-beautify (from cssbeautifier) on .css files" - entry: css-beautify --end-with-newline --replace - language: python - additional_dependencies: ["cssbeautifier==1.15.1"] - files: ^(?!.*\.min\.css$)(?!static/(js|css)/vendor/).*\.css$ - exclude: ^frontend/ - id: update-installer-checksum name: Update sep_installer.sh checksum description: "Update sep_installer.sha256 when sep_installer.sh changes" diff --git a/Makefile b/Makefile index 965e4a7410..c0d51b7ce5 100644 --- a/Makefile +++ b/Makefile @@ -60,7 +60,7 @@ build: venv app/ pack: ifndef BUNDLE @echo Exporting bundle - @git archive --output=bundle.tgz --format=tar.gz "${RELEASE_VER}" app snippets static templates + @git archive --output=bundle.tgz --format=tar.gz "${RELEASE_VER}" app snippets else @echo Copying custom bundle "${BUNDLE}" @cp -a "${BUNDLE}" bundle.tgz @@ -87,7 +87,6 @@ image-sidecar-embedded: pack format: venv @"${VENV_BIN}"/ruff format . - @"${VENV_BIN}"/djlint . --reformat ruff: venv @"${VENV_BIN}"/ruff check . @@ -99,11 +98,7 @@ ruff: venv typecheck: venv @"${VENV_BIN}"/ty check app -djlint: venv - @"${VENV_BIN}"/djlint . - @"${VENV_BIN}"/djlint . --check - -lint: ruff djlint +lint: ruff audit: bandit pip-audit @@ -357,4 +352,4 @@ lint-pipelines: done; \ if [ "$${failures}" -ne 0 ]; then exit 1; fi -.PHONY: venv build pack builder image image-sidecar image-sidecar-embedded format ruff typecheck djlint lint audit run-pre-commit dev-backend dev-frontend backfill-legacy-forms pip-audit bandit makemigrations makemigrations-plugin migrate checkmigrations test regen-specs regen-pbm-payloads regen-pbm-payloads-check release-prep release-rc release-stable trigger-jenkins lint-pipelines changelog-add changelog-check changelog-list startapp startapp-check +.PHONY: venv build pack builder image image-sidecar image-sidecar-embedded format ruff typecheck lint audit run-pre-commit dev-backend dev-frontend backfill-legacy-forms pip-audit bandit makemigrations makemigrations-plugin migrate checkmigrations test regen-specs regen-pbm-payloads regen-pbm-payloads-check release-prep release-rc release-stable trigger-jenkins lint-pipelines changelog-add changelog-check changelog-list startapp startapp-check diff --git a/app/api/routes/oauth.py b/app/api/routes/oauth.py index a500c9e695..26dad5015e 100644 --- a/app/api/routes/oauth.py +++ b/app/api/routes/oauth.py @@ -203,8 +203,7 @@ async def spa_session_exchange( Carries no auth dependency by design: the caller is not yet SEP-authenticated, and the ambient session cookie is the credential being presented. No CSRF - primitive applies either -- ``IsCsrfValidated`` guards form data on the - server-rendered login route, and requiring a Bearer token cannot gate an + primitive applies either: requiring a Bearer token cannot gate an endpoint whose purpose is to issue one. That is safe because a cross-origin attacker cannot read this response (the CORS posture is an explicit per-environment origin allowlist, with the middleware absent when none is diff --git a/app/core/db/utils.py b/app/core/db/utils.py index 63293c4444..25d38dbc6e 100644 --- a/app/core/db/utils.py +++ b/app/core/db/utils.py @@ -344,6 +344,23 @@ def acquire_pg_advisory_xact_lock(bind: Connection, lock_key: int) -> None: bind.execute(text("SELECT pg_advisory_xact_lock(:key)"), {"key": lock_key}) +def table_exists(bind: Connection, table_name: str) -> bool: + """Return whether ``table_name`` is present on the bound database. + + Enum-widening downgrades need this as a separate preflight from + :func:`check_constraint_lists_members`. That helper collapses "the table is + gone" and "the member is not listed" into a single ``False``, which reads + correctly for a narrowing guard (``if not lists_members: return``) but + inverts for a widening one (``if lists_members: return``) — there, a missing + table falls through into DDL against a table another track already dropped. + + :param bind: The migration's bound connection (``op.get_bind()``). + :param table_name: The table to test for. + :return: ``True`` when the table exists. + """ + return inspect(bind).has_table(table_name) + + def check_constraint_lists_members( bind: Connection, table_name: str, @@ -358,10 +375,17 @@ def check_constraint_lists_members( tests membership by matching each value as a single-quoted SQL string literal, so ``"SETTINGS"`` does not spuriously match ``"SEP_SETTINGS"``. - Returns ``False`` when the table does not exist -- ``get_check_constraints`` + Returns ``False`` when the table does not exist. ``get_check_constraints`` raises ``NoSuchTableError`` for a missing table, and a missing table means - there is no constraint to list anything (so an enum-narrowing downgrade - correctly no-ops when another track already dropped the shared table). + there is no constraint to list anything. + + That single ``False`` carries two meanings, so it only short-circuits DDL + for a guard written ``if not lists_members: return`` (enum widening on + upgrade, narrowing on downgrade). A guard with the opposite polarity — + ``if lists_members: return``, as enum *narrowing* needs on upgrade and its + widening downgrade needs in reverse — falls through on a missing table and + runs DDL against it. Those call sites must precede this check with + :func:`table_exists`. :param bind: The migration's bound connection (``op.get_bind()``). :param table_name: The table whose CHECK constraints are inspected. diff --git a/app/core/exceptions.py b/app/core/exceptions.py index f141876102..f341ea79d7 100644 --- a/app/core/exceptions.py +++ b/app/core/exceptions.py @@ -18,7 +18,6 @@ from typing import Any from fastapi import HTTPException, status -from starlette.exceptions import HTTPException as StarletteHTTPException class HTTPNotFoundException(HTTPException): @@ -184,21 +183,3 @@ def __init__( super().__init__( status_code=status.HTTP_410_GONE, detail=detail, headers=headers ) - - -class HTTPRedirectException(StarletteHTTPException): - """Define exception raised for redirects. - - :param location: The URL to which the client should be redirected. - :type location: str - :param status_code: The HTTP status code for the redirect response. Defaults to - 307 (Temporary Redirect). - :type status_code: int - """ - - def __init__( - self, location: str, status_code: int = status.HTTP_307_TEMPORARY_REDIRECT - ) -> None: - self.location = location - super().__init__(status_code=status_code) - self.headers = {"Location": location} diff --git a/app/core/settings_override/api/routes.py b/app/core/settings_override/api/routes.py index c6b3d6812b..8ec6880d98 100644 --- a/app/core/settings_override/api/routes.py +++ b/app/core/settings_override/api/routes.py @@ -826,10 +826,9 @@ def build_settings_router( dependency, and its admin-auth dependency. ``admin_dep`` is applied at the router level so every endpoint inherits the admin gate. State-changing endpoints (PATCH / DELETE) additionally take ``mutation_deps``, which the - SEP wiring uses to require Bearer authentication on mutations: a - cookie-authenticated admin can otherwise be CSRF'd into mutating settings - because :func:`app.sep.deps.validate_csrf` only inspects form bodies, and - JSON mutations carry no form body. + SEP wiring uses to require Bearer authentication on mutations, so a + cross-site JSON request carrying only ambient cookies cannot mutate + settings. :param classes: One ``(SettingClassEnum, settings_cls, proxy)`` triple per core settings class to expose on this router. diff --git a/app/core/settings_override/models.py b/app/core/settings_override/models.py index 46a5dac989..26a78d723b 100644 --- a/app/core/settings_override/models.py +++ b/app/core/settings_override/models.py @@ -32,9 +32,8 @@ class SettingClassEnum(StrEnum): """Enumerate settings classes that may have HOT override rows. The wired classes are ``SEPSettings``, ``TasksSettings``, - ``SnippetsSettings``, ``MessagesSettings``, the global ``Settings``, - ``AlertSettings``, ``AlertsSettings``, ``AnonymizerSettings`` and - ``InventorySettings``. + ``SnippetsSettings``, the global ``Settings``, ``AlertSettings``, + ``AlertsSettings``, ``AnonymizerSettings`` and ``InventorySettings``. To wire a new settings class: @@ -53,7 +52,6 @@ class SettingClassEnum(StrEnum): SEP_SETTINGS = "SEPSettings" TASKS_SETTINGS = "TasksSettings" SNIPPETS_SETTINGS = "SnippetsSettings" - MESSAGES_SETTINGS = "MessagesSettings" SETTINGS = "Settings" ALERT_SETTINGS = "AlertSettings" ANONYMIZER_SETTINGS = "AnonymizerSettings" diff --git a/app/inventory/migrations/versions/2026_08_08_0306-34e6108ea194_drop_messages_setting_overrides.py b/app/inventory/migrations/versions/2026_08_08_0306-34e6108ea194_drop_messages_setting_overrides.py new file mode 100644 index 0000000000..c332f62f24 --- /dev/null +++ b/app/inventory/migrations/versions/2026_08_08_0306-34e6108ea194_drop_messages_setting_overrides.py @@ -0,0 +1,114 @@ +# Copyright (C) 2026 Percona LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""drop messages setting overrides + +Revision ID: 34e6108ea194 +Revises: c4d5e6f7a8b9 +Create Date: 2026-08-08 03:06:23.354353 + +""" + +import sqlalchemy as sa +from alembic import op + +from app.core.db.utils import ( + acquire_pg_advisory_xact_lock, + check_constraint_lists_members, + table_exists, +) +from app.core.settings_override.constants import SETTINGOVERRIDE_MIGRATION_LOCK_KEY + +# revision identifiers, used by Alembic. +revision = "34e6108ea194" +down_revision = "c4d5e6f7a8b9" +branch_labels = None +depends_on = None + +_OLD_MEMBERS = ( + "SEP_SETTINGS", + "TASKS_SETTINGS", + "SNIPPETS_SETTINGS", + "MESSAGES_SETTINGS", + "SETTINGS", + "ALERT_SETTINGS", + "ANONYMIZER_SETTINGS", + "ALERTS_SETTINGS", + "INVENTORY_SETTINGS", +) +_NEW_MEMBERS = tuple(m for m in _OLD_MEMBERS if m != "MESSAGES_SETTINGS") + + +def upgrade() -> None: + """Remove ``MESSAGES_SETTINGS`` from the setting_class constraint.""" + bind = op.get_bind() + acquire_pg_advisory_xact_lock(bind, SETTINGOVERRIDE_MIGRATION_LOCK_KEY) + if not check_constraint_lists_members( + bind, "settingoverride", "setting_class", ("MESSAGES_SETTINGS",) + ): + return + # The narrowed CHECK re-validates existing data, so the rows carrying the + # removed member have to go first or the ALTER aborts. + op.execute("DELETE FROM settingoverride WHERE setting_class = 'MESSAGES_SETTINGS'") + with op.batch_alter_table("settingoverride", schema=None) as batch_op: + batch_op.alter_column( + "setting_class", + existing_type=sa.Enum( + *_OLD_MEMBERS, + name="settingclassenum", + native_enum=False, + create_constraint=True, + ), + type_=sa.Enum( + *_NEW_MEMBERS, + name="settingclassenum", + native_enum=False, + create_constraint=True, + ), + existing_nullable=False, + ) + + +def downgrade() -> None: + """Restore ``MESSAGES_SETTINGS`` in the setting_class constraint. + + The deleted rows are not recreated: they carried a flash-message level for a + middleware that no longer exists. + """ + bind = op.get_bind() + acquire_pg_advisory_xact_lock(bind, SETTINGOVERRIDE_MIGRATION_LOCK_KEY) + if not table_exists(bind, "settingoverride"): + return + if check_constraint_lists_members( + bind, "settingoverride", "setting_class", ("MESSAGES_SETTINGS",) + ): + return + with op.batch_alter_table("settingoverride", schema=None) as batch_op: + batch_op.alter_column( + "setting_class", + existing_type=sa.Enum( + *_NEW_MEMBERS, + name="settingclassenum", + native_enum=False, + create_constraint=True, + ), + type_=sa.Enum( + *_OLD_MEMBERS, + name="settingclassenum", + native_enum=False, + create_constraint=True, + ), + existing_nullable=False, + ) diff --git a/app/sep/api/router.py b/app/sep/api/router.py index 3eb90e3c12..fd380d67ef 100644 --- a/app/sep/api/router.py +++ b/app/sep/api/router.py @@ -58,10 +58,6 @@ def build_apps_router(registry: AppRegistry) -> APIRouter: """Build the ``/apps`` sub-router by iterating the app registry. - Mirror the Jinja UI mount loop in ``app/sep/main.py`` so future - runtime enable/disable guards can be applied symmetrically at both - mount points. - :param registry: The app registry, in activation order. :type registry: AppRegistry :return: An ``APIRouter`` mounted at ``/apps`` with each app whose @@ -92,7 +88,9 @@ def build_apps_router(registry: AppRegistry) -> APIRouter: apps_router = build_apps_router(get_app_registry()) -api_router = APIRouter(prefix="/api", dependencies=[IsApiAuthenticated]) +api_router = APIRouter( + prefix="/api", dependencies=[IsApiAuthenticated, RequireBearerForUnsafeMethods] +) api_router.include_router(apps_router) api_router.include_router(app_info_router, prefix="/sep/app-info", tags=["sep"]) api_router.include_router(dashboard_router, prefix="/sep/dashboard", tags=["sep"]) diff --git a/app/sep/api/routes/settings.py b/app/sep/api/routes/settings.py index c6b0bac7f8..992f510cac 100644 --- a/app/sep/api/routes/settings.py +++ b/app/sep/api/routes/settings.py @@ -45,7 +45,6 @@ ) from app.sep.config import sep_settings, SEPSettings from app.sep.deps import IsApiAdmin, RequireBearerForUnsafeMethods, SessionDep, TaskAPI -from app.sep.middleware.messages.config import messages_settings, MessagesSettings from app.sep.snippets.config import snippets_settings, SnippetsSettings # TasksSettings is owned by the Tasks sub-app, so SEP proxies it server-side @@ -54,7 +53,6 @@ SEP_ADMIN_SETTINGS_CLASSES: list[ClassEntry] = [ (SettingClassEnum.SEP_SETTINGS, SEPSettings, sep_settings), (SettingClassEnum.SNIPPETS_SETTINGS, SnippetsSettings, snippets_settings), - (SettingClassEnum.MESSAGES_SETTINGS, MessagesSettings, messages_settings), (SettingClassEnum.ALERT_SETTINGS, AlertSettings, alert_settings), # The global ``Settings`` class is refreshed only by the SEP web process, so # its override-eligible fields (e.g. ``PMM``, ``LOGGING``) are exposed here. diff --git a/app/sep/apps/alert_troubleshooting/__init__.py b/app/sep/apps/alert_troubleshooting/__init__.py index 4266bc2abd..4c4e336b8f 100644 --- a/app/sep/apps/alert_troubleshooting/__init__.py +++ b/app/sep/apps/alert_troubleshooting/__init__.py @@ -16,6 +16,5 @@ """Alert Troubleshooting plugin — index page with alert selection by service type.""" from app.sep.apps.alert_troubleshooting.app import app -from app.sep.apps.alert_troubleshooting.routes import router -__all__ = ["app", "router"] +__all__ = ["app"] diff --git a/app/sep/apps/alert_troubleshooting/app.py b/app/sep/apps/alert_troubleshooting/app.py index ac209eb5e1..27895c4e71 100644 --- a/app/sep/apps/alert_troubleshooting/app.py +++ b/app/sep/apps/alert_troubleshooting/app.py @@ -17,16 +17,14 @@ alert_troubleshooting projects alerts out of snippet frontmatter and exposes a composite-keyed JSON surface — alerts grouped by service type and a -``(service_type, alert_name)`` detail with linked snippets — plus a deprecated -Jinja UI whose ``POST /execute`` reuses the snippets execution path. None of that -maps onto the framework's filename-keyed ``ScriptSource`` derived routes, so it is +``(service_type, alert_name)`` detail with linked snippets. That does not map +onto the framework's filename-keyed ``ScriptSource`` derived routes, so it is exported as a plain :class:`BaseApp` carrying its hand-written ``api_router`` unchanged, the same shape as ``dipper`` and the bespoke ``inventory``/``report`` -apps. The legacy Jinja UI is threaded as ``jinja_router``. +apps. """ from app.sep.apps.alert_troubleshooting.api_routes import router as api_router -from app.sep.apps.alert_troubleshooting.routes import router as jinja_router from app.sep.apps.framework.base import BaseApp from app.sep.apps.nav_icons import NavIcon @@ -40,6 +38,5 @@ react_route="/alerts/troubleshooting", nav_icon=NavIcon.TROUBLESHOOT, api_router=api_router, - jinja_router=jinja_router, uses_task_data=True, ) diff --git a/app/sep/apps/alert_troubleshooting/deps.py b/app/sep/apps/alert_troubleshooting/deps.py index 40105d14d5..9d3ab9ff07 100644 --- a/app/sep/apps/alert_troubleshooting/deps.py +++ b/app/sep/apps/alert_troubleshooting/deps.py @@ -19,31 +19,16 @@ import re from collections.abc import Iterable from contextlib import suppress -from typing import Annotated, Any +from typing import Any -from fastapi import Depends, Request from pydantic import BaseModel, model_validator, ValidationError -from app.core.exceptions import ( - HTTPBadRequestException, - HTTPNotFoundException, - HTTPRedirectException, -) +from app.core.exceptions import HTTPNotFoundException from app.core.utils.fields import NonEmptyStr -from app.sep.deps import DefaultContext, ExecutorHostsCtx, SessionDep +from app.sep.deps import SessionDep from app.sep.models import AlertServiceType from app.sep.snippets.crud import SnippetManager -from app.sep.snippets.deps import ( - get_snippet_execution_request_meta, - get_snippet_source, - get_validated_execution_args, - SnippetDep, -) -from app.sep.snippets.models.snippet import ( - BaseSnippetArgs, - Snippet, - SnippetExecutionMeta, -) +from app.sep.snippets.models.snippet import Snippet logger = logging.getLogger(__name__) @@ -237,36 +222,6 @@ async def get_grouped_alerts( return collect_grouped_alerts(snippets) -GroupedAlertsDep = Annotated[ - dict[AlertServiceType, list[AlertInfo]], - Depends(get_grouped_alerts), -] - - -async def get_troubleshooting_index_context( - context: DefaultContext, - grouped_alerts: GroupedAlertsDep, -) -> dict[str, Any]: - """Assemble the template context for the Alert Troubleshooting index page. - - :param context: The default template context with user and base URI. - :type context: DefaultContext - :param grouped_alerts: Alerts grouped by service type. - :type grouped_alerts: GroupedAlertsDep - :return: The updated context dictionary for the index template. - :rtype: dict[str, Any] - """ - context["grouped_alerts"] = grouped_alerts - context["alert_service_types"] = list(AlertServiceType) - return context - - -TroubleshootingIndexContext = Annotated[ - dict[str, Any], - Depends(get_troubleshooting_index_context), -] - - def _get_normalized_alerts(snippet: Snippet) -> list[AlertInfo]: """Extract and normalize all alert entries from a snippet's metadata. @@ -362,119 +317,3 @@ async def get_snippets_for_alert( """ snippets = await SnippetManager.list(session) return filter_snippets_for_alert(snippets, alert_name, service_type) - - -AlertSnippetsDep = Annotated[ - tuple[list[Snippet], AlertInfo], Depends(get_snippets_for_alert) -] - - -async def get_troubleshooting_detail_context( - context: DefaultContext, - snippets_and_alert: AlertSnippetsDep, - executor_hosts_ctx: ExecutorHostsCtx, -) -> dict[str, Any]: - """Assemble the template context for the Alert Troubleshooting detail page. - - :param context: The default template context with user and base URI. - :type context: DefaultContext - :param snippets_and_alert: The snippets and alert info from filtering. - :type snippets_and_alert: AlertSnippetsDep - :param executor_hosts_ctx: The executor hosts context with display names. - :type executor_hosts_ctx: ExecutorHostsCtx - :return: The updated context dictionary for the detail template. - :rtype: dict[str, Any] - """ - snippets, alert_info = snippets_and_alert - context |= { - "alert_info": alert_info, - "snippets": snippets, - "executor_hosts": executor_hosts_ctx.as_template_list(), - } - return context - - -TroubleshootingDetailContext = Annotated[ - dict[str, Any], - Depends(get_troubleshooting_detail_context), -] - - -def get_ajax_executable_snippet( - snippet: SnippetDep, -) -> Snippet: - """Verify snippet executability and return a JSON error on failure. - - Wrap the standard executable snippet check for AJAX endpoints by - raising an ``HTTPBadRequestException`` instead of an - ``HTTPRedirectException``. - - :param snippet: The snippet to verify. - :type snippet: SnippetDep - :return: The verified executable snippet. - :rtype: Snippet - :raises HTTPBadRequestException: If the snippet cannot be executed. - """ - if snippet.can_execute: - return snippet - raise HTTPBadRequestException(detail=f"Snippet {snippet} cannot be executed") - - -AjaxExecutableSnippet = Annotated[Snippet, Depends(get_ajax_executable_snippet)] - - -async def get_ajax_validated_execution_args( - request: Request, - snippet: AjaxExecutableSnippet, -) -> BaseSnippetArgs: - """Validate execution arguments and return JSON errors on failure. - - Wrap the standard argument validation for AJAX endpoints by catching - ``HTTPRedirectException`` and raising ``HTTPBadRequestException`` - instead. - - :param request: The HTTP request object. - :type request: Request - :param snippet: The executable snippet. - :type snippet: AjaxExecutableSnippet - :return: The validated execution arguments. - :rtype: BaseSnippetArgs - :raises HTTPBadRequestException: If the execution arguments are invalid. - """ - try: - return await get_validated_execution_args(request, snippet) - except HTTPRedirectException: - raise HTTPBadRequestException( - detail="Invalid execution parameters", - ) from None - - -def get_ajax_execution_request_meta( - snippet: AjaxExecutableSnippet, - snippet_source: Annotated[str, Depends(get_snippet_source)], - execution_args: Annotated[ - BaseSnippetArgs, Depends(get_ajax_validated_execution_args) - ], -) -> SnippetExecutionMeta: - """Prepare execution metadata for an AJAX snippet execution request. - - Delegate to the standard snippet execution metadata builder but use - AJAX-safe dependency validation that returns JSON errors instead of - redirect responses. - - :param snippet: The executable snippet. - :type snippet: AjaxExecutableSnippet - :param snippet_source: The signed URL to download the snippet artifact. - :type snippet_source: str - :param execution_args: The validated execution arguments. - :type execution_args: BaseSnippetArgs - :return: The prepared execution metadata. - :rtype: SnippetExecutionMeta - """ - return get_snippet_execution_request_meta(snippet, snippet_source, execution_args) - - -ExecutionRequestMeta = Annotated[ - SnippetExecutionMeta, - Depends(get_ajax_execution_request_meta), -] diff --git a/app/sep/apps/alert_troubleshooting/routes.py b/app/sep/apps/alert_troubleshooting/routes.py deleted file mode 100644 index 73c707e1ed..0000000000 --- a/app/sep/apps/alert_troubleshooting/routes.py +++ /dev/null @@ -1,230 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define routes for the Alert Troubleshooting plugin.""" - -import json -import logging - -from fastapi import APIRouter, HTTPException, Request, status -from fastapi.responses import HTMLResponse, JSONResponse - -from app.core.requests import RemoteAPI -from app.sep.apps.alert_troubleshooting.deps import ( - AjaxExecutableSnippet, - ExecutionRequestMeta, - TroubleshootingDetailContext, - TroubleshootingIndexContext, -) -from app.sep.apps.framework.deprecation import DeprecatedJinja2Route -from app.sep.apps.framework.script_helpers import post_task_execution -from app.sep.config import sep_settings -from app.sep.deps import IsAuthenticated, IsCsrfValidated, TaskAPI - -logger = logging.getLogger(__name__) -router = APIRouter(route_class=DeprecatedJinja2Route) -templates = sep_settings.TEMPLATES - -_TERMINAL_STATUSES = frozenset({"success", "failed", "stopped", "stale"}) - - -async def _collect_output( - tasks_api: RemoteAPI, task_history_id: int -) -> tuple[str, str]: - """Stream task logs and collect STDOUT and STDERR content. - - :param tasks_api: The authenticated Tasks API client. - :type tasks_api: RemoteAPI - :param task_history_id: The task history ID to fetch logs for. - :type task_history_id: int - :return: A tuple of (stdout, stderr) concatenated output. - :rtype: tuple[str, str] - """ - stdout_parts = [] - stderr_parts = [] - async for chunk in tasks_api.stream( - f"/history/{task_history_id}/logs/", - params={"step": "run-script"}, - ): - if chunk: - log_entry = json.loads(chunk) - msg = log_entry.get("msg") - if not msg: - continue - entry_type = log_entry.get("type") - if entry_type == "stdout": - stdout_parts.append(msg) - elif entry_type == "stderr": - stderr_parts.append(msg) - return "".join(stdout_parts), "".join(stderr_parts) - - -@router.get("/", dependencies=[IsAuthenticated], response_class=HTMLResponse) -async def troubleshooting_index( - request: Request, - context: TroubleshootingIndexContext, -) -> HTMLResponse: - """Render the Alert Troubleshooting index page. - - Display available alerts grouped by database service type in expandable - accordion sections. - - :param request: The HTTP request object. - :type request: Request - :param context: The assembled template context with grouped alerts. - :type context: TroubleshootingIndexContext - :return: The rendered HTML response. - :rtype: HTMLResponse - """ - return templates.TemplateResponse( - request=request, - name="alert_troubleshooting/index.html.j2", - context=context, - ) - - -@router.post( - "/execute", - dependencies=[IsAuthenticated, IsCsrfValidated], -) -async def troubleshooting_execute( - tasks_api: TaskAPI, - snippet: AjaxExecutableSnippet, - execution_request_meta: ExecutionRequestMeta, -) -> JSONResponse: - """Execute a snippet via AJAX and return the task ID as JSON. - - Proxy the execution request to the Tasks API and return the task history - ID for subsequent output polling. The caller passes ``snippet_filename`` as - a query parameter so nested snippet keys do not occupy a routed path segment. - - :param tasks_api: The authenticated Tasks API client. - :type tasks_api: TaskAPI - :param snippet: The validated executable snippet. - :type snippet: AjaxExecutableSnippet - :param execution_request_meta: The assembled execution metadata. - :type execution_request_meta: ExecutionRequestMeta - :return: A JSON response with the task ID and submission status. - :rtype: JSONResponse - """ - try: - task_id = await post_task_execution( - tasks_api, snippet.execution_task_name, execution_request_meta - ) - except HTTPException as exc: - logger.warning( - "HTTP error executing snippet %r: %s %s", - snippet.filename, - exc.status_code, - exc.detail, - ) - return JSONResponse( - {"error": exc.detail or "Execution failed"}, - status_code=exc.status_code, - ) - if task_id is None: - logger.warning( - "Unexpected response from Tasks API for snippet %r", - snippet.filename, - ) - return JSONResponse( - {"error": "Unexpected response from Tasks API"}, - status_code=status.HTTP_502_BAD_GATEWAY, - ) - logger.info( - "Troubleshooting execution submitted for snippet %r, task %s", - snippet.filename, - task_id, - ) - return JSONResponse({"task_id": task_id, "status": "submitted"}) - - -@router.get("/output/{task_history_id}", dependencies=[IsAuthenticated]) -async def troubleshooting_output( - task_history_id: int, - tasks_api: TaskAPI, -) -> JSONResponse: - """Poll the execution status and output for a task history entry. - - Return the current task status and, when finished, the STDOUT content - from the task's logs. When the task has failed and STDERR is - available, include it in the ``error`` field so the client can display - the failure reason. - - :param task_history_id: The task history ID to poll. - :type task_history_id: int - :param tasks_api: The authenticated Tasks API client. - :type tasks_api: TaskAPI - :return: A JSON response with the task status, optional output, and - optional error. - :rtype: JSONResponse - """ - try: - history = await tasks_api.get(f"/history/{task_history_id}/") - except HTTPException as exc: - logger.warning( - "HTTP error polling task %s: %s %s", - task_history_id, - exc.status_code, - exc.detail, - ) - return JSONResponse( - {"error": exc.detail or "Failed to retrieve task output"}, - status_code=exc.status_code, - ) - task_status = history.get("status", "unknown") - output = "" - stderr = "" - if task_status in _TERMINAL_STATUSES: - try: - output, stderr = await _collect_output(tasks_api, task_history_id) - except (HTTPException, KeyError, TypeError, ValueError, OSError): - logger.debug( - "Could not fetch logs for task %s", - task_history_id, - exc_info=True, - ) - response = {"status": task_status, "output": output} - if task_status == "failed" and stderr: - response["error"] = stderr - return JSONResponse(response) - - -@router.get( - "/{service_type}/{alert_name}", - dependencies=[IsAuthenticated], - response_class=HTMLResponse, -) -async def troubleshooting_detail( - request: Request, - context: TroubleshootingDetailContext, -) -> HTMLResponse: - """Render the Alert Troubleshooting detail page for a specific alert. - - Display the alert header, shared host selector, and snippet cards with - parameter forms for AJAX-based execution. - - :param request: The HTTP request object. - :type request: Request - :param context: The assembled template context with alert info and snippets. - :type context: TroubleshootingDetailContext - :return: The rendered HTML response. - :rtype: HTMLResponse - """ - return templates.TemplateResponse( - request=request, - name="alert_troubleshooting/detail.html.j2", - context=context, - ) diff --git a/app/sep/apps/alerts/__init__.py b/app/sep/apps/alerts/__init__.py index 334e5adbde..e1b01917b8 100644 --- a/app/sep/apps/alerts/__init__.py +++ b/app/sep/apps/alerts/__init__.py @@ -15,6 +15,5 @@ from app.sep.apps.alerts.app import app from app.sep.apps.alerts.app_owned_settings import APP_OWNED_SETTINGS_CLASSES -from app.sep.apps.alerts.routes import router -__all__ = ["APP_OWNED_SETTINGS_CLASSES", "app", "router"] +__all__ = ["APP_OWNED_SETTINGS_CLASSES", "app"] diff --git a/app/sep/apps/alerts/app.py b/app/sep/apps/alerts/app.py index 68dd183ebd..17fe7eb8cc 100644 --- a/app/sep/apps/alerts/app.py +++ b/app/sep/apps/alerts/app.py @@ -17,14 +17,13 @@ Register the bespoke alerts plugin through the registry's definition path (``getattr(module, "app")``) instead of the synthesized-legacy fallback, -exposing the same JSON and Jinja routers the registry imports today, and +exposing the same JSON router the registry imports today, and contribute the alert-config backup beat schedule via ``periodic_task_schedules``. """ from app.sep.apps.alerts.api_routes import router as api_router from app.sep.apps.alerts.config import alerts_settings -from app.sep.apps.alerts.routes import router as jinja_router from app.sep.apps.framework.base import AppPeriodicTask, BaseApp from app.sep.apps.nav_icons import NavIcon @@ -38,7 +37,6 @@ react_route="/alerts/templates", nav_icon=NavIcon.DESCRIPTION, api_router=api_router, - jinja_router=jinja_router, periodic_task_schedules=[ AppPeriodicTask( name="sep__backup_alert_config", diff --git a/app/sep/apps/alerts/deps.py b/app/sep/apps/alerts/deps.py index 1fe7e9de8b..a4922f6291 100644 --- a/app/sep/apps/alerts/deps.py +++ b/app/sep/apps/alerts/deps.py @@ -34,7 +34,6 @@ # live in ``app.sep.deps`` alongside the sibling Inventory / Tasks client deps; # they are re-exported here for existing importers (routes, celery, tests). from app.sep.deps import ( - DefaultContext, get_pmm_api, # noqa: F401 -- re-exported for existing importers PMMAPIDep, require_pmm_api, # noqa: F401 -- re-exported for existing importers @@ -218,38 +217,3 @@ async def ensure_pagerduty_notification_route( } ) await pmm_api.update_notification_policy(policy) - - -async def get_alerts_index_context( - context: DefaultContext, - alert_templates: AlertTemplatesDep, - pmm_present_names: PMMPresentNamesDep, - recent_backups: RecentBackupsDep, - pagerduty_status: PagerDutyStatusDep, -) -> dict[str, Any]: - """Assemble the template context for the alerts plugin index view. - - :param context: The default template context with user, plugins, etc. - :param alert_templates: Alert templates grouped by service type. - :param pmm_present_names: Set of template names present in PMM, or ``None`` - when PMM is unreachable. - :param recent_backups: The most recent alert backups for the sidebar widget. - :param pagerduty_status: PagerDuty contact point status for the sidebar - widget, or ``None`` when PMM is unreachable. - :return: The updated context dictionary with alerts data. - """ - all_templates = [t for templates in alert_templates.values() for t in templates] - context.update( - { - "alert_templates": alert_templates, - "all_templates": all_templates, - "service_types": list(ServiceType), - "pmm_present_names": pmm_present_names, - "recent_backups": recent_backups, - "pagerduty_status": pagerduty_status, - } - ) - return context - - -AlertsIndexContext = Annotated[dict[str, Any], Depends(get_alerts_index_context)] diff --git a/app/sep/apps/alerts/routes.py b/app/sep/apps/alerts/routes.py deleted file mode 100644 index bf79da1a06..0000000000 --- a/app/sep/apps/alerts/routes.py +++ /dev/null @@ -1,357 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define routes for the alerts plugin.""" - -import logging -from typing import Annotated - -from fastapi import APIRouter, Form, Request, status -from fastapi.exceptions import HTTPException -from fastapi.responses import HTMLResponse, JSONResponse - -from app.core.utils.fields import NonEmptyStr -from app.sep.apps.alerts.config import alerts_settings -from app.sep.apps.alerts.crud import AlertBackupManager -from app.sep.apps.alerts.deps import ( - AlertsIndexContext, - AlertTemplatesDep, - ensure_pagerduty_notification_route, - find_pagerduty_contact_point, - PAGERDUTY_CONTACT_POINT_NAME, - PMMAPIDep, - PMMPresentNamesDep, - RequiredAlertFolderDep, - RequiredPMMAPIDep, -) -from app.sep.apps.alerts.models import DEFAULT_FOR_DURATION, to_pmm_template_yaml -from app.sep.apps.alerts.restore import delete_conflicting_rules, restore_from_backup -from app.sep.apps.framework.deprecation import DeprecatedJinja2Route -from app.sep.config import sep_settings -from app.sep.deps import IsAuthenticated, IsCsrfValidated, SessionDep - -logger = logging.getLogger(__name__) -router = APIRouter(route_class=DeprecatedJinja2Route) -templates = sep_settings.TEMPLATES - - -@router.get( - "/", - dependencies=[IsAuthenticated], - response_class=HTMLResponse, - include_in_schema=False, -) -async def alerts_index( - request: Request, - context: AlertsIndexContext, -) -> HTMLResponse: - """Render the alert templates list page.""" - return templates.TemplateResponse( - request=request, - name="alerts/index.html.j2", - context=context, - ) - - -@router.post( - "/restore", dependencies=[IsAuthenticated, IsCsrfValidated], include_in_schema=False -) -async def alerts_restore( - pmm_api: PMMAPIDep, - session: SessionDep, - backup_id: Annotated[int, Form()], -) -> JSONResponse: - """Restore alert configuration from a selected backup. - - :param pmm_api: The PMM API client, or ``None`` when PMM is not configured. - :type pmm_api: PMMRemoteAPI | None - :param session: The async database session. - :type session: AsyncSession - :param backup_id: The ID of the backup to restore from. - :type backup_id: int - :return: JSON with restore results on success or an error envelope on failure. - :rtype: JSONResponse - """ - if pmm_api is None: - return JSONResponse( - {"status": "error", "message": "PMM is not configured"}, - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - ) - try: - backup = await AlertBackupManager.get_or_404(session, id=backup_id) - results = await restore_from_backup(pmm_api, backup) - except HTTPException as exc: - logger.exception("Failed to restore alert configuration from backup") - return JSONResponse( - {"status": "error", "message": exc.detail}, - status_code=exc.status_code, - ) - except OSError as exc: - logger.exception("Failed to restore alert configuration from backup") - return JSONResponse( - {"status": "error", "message": str(exc)}, - status_code=status.HTTP_502_BAD_GATEWAY, - ) - return JSONResponse({"status": "success", "details": results}) - - -@router.get( - "/backups/{backup_id}", dependencies=[IsAuthenticated], include_in_schema=False -) -async def alerts_backup_detail(session: SessionDep, backup_id: int) -> JSONResponse: - """Return a summary of a single backup's contents. - - :param session: The async database session. - :type session: AsyncSession - :param backup_id: The ID of the backup to retrieve. - :type backup_id: int - :return: JSON with categorized backup contents. - :rtype: JSONResponse - """ - backup = await AlertBackupManager.get(session, id=backup_id) - if backup is None: - return JSONResponse( - {"status": "error", "message": "Backup not found"}, - status_code=status.HTTP_404_NOT_FOUND, - ) - data = backup.data - return JSONResponse( - { - "id": backup.id, - "created_at": backup.created_at.strftime("%Y-%m-%d %H:%M UTC"), - "templates": [ - {"name": t.get("name", ""), "summary": t.get("summary", "")} - for t in data.get("templates", []) - ], - "rules": [{"title": r.get("title", "")} for r in data.get("rules", [])], - "contact_points": [ - {"name": cp.get("name", ""), "type": cp.get("type", "")} - for cp in data.get("contact_points", []) - ], - "folders": [{"title": f.get("title", "")} for f in data.get("folders", [])], - "notification_policy_receiver": data.get("notification_policy", {}).get( - "receiver", "" - ), - } - ) - - -@router.post( - "/pagerduty", - dependencies=[IsAuthenticated, IsCsrfValidated], - include_in_schema=False, -) -async def pagerduty_save( - pmm_api: RequiredPMMAPIDep, - integration_key: Annotated[NonEmptyStr, Form()], -) -> JSONResponse: - """Create or update the PagerDuty contact point and notification policy. - - :param pmm_api: The PMM API client dependency. - :type pmm_api: PMMRemoteAPI - :param integration_key: The PagerDuty integration key from the form. - :type integration_key: NonEmptyStr - :return: JSON with ``status``. - :rtype: JSONResponse - """ - try: - contact_points = await pmm_api.list_contact_points() - pd_cp = find_pagerduty_contact_point(contact_points) - pd_settings = {"integrationKey": integration_key} - - if pd_cp is not None: - await pmm_api.update_contact_point( - pd_cp.uid, - PAGERDUTY_CONTACT_POINT_NAME, - "pagerduty", - pd_settings, - ) - result_status = "updated" - else: - await pmm_api.create_contact_point( - PAGERDUTY_CONTACT_POINT_NAME, - "pagerduty", - pd_settings, - ) - result_status = "created" - - await ensure_pagerduty_notification_route(pmm_api, PAGERDUTY_CONTACT_POINT_NAME) - - return JSONResponse({"status": result_status}) - except (HTTPException, OSError): - logger.exception("Failed to save PagerDuty contact point") - return JSONResponse( - {"error": "Failed to save PagerDuty configuration"}, - status_code=status.HTTP_502_BAD_GATEWAY, - ) - - -@router.post( - "/pagerduty/delete", - dependencies=[IsAuthenticated, IsCsrfValidated], - include_in_schema=False, -) -async def pagerduty_delete(pmm_api: RequiredPMMAPIDep) -> JSONResponse: - """Delete the PagerDuty contact point and remove its notification route. - - :param pmm_api: The PMM API client dependency. - :type pmm_api: PMMRemoteAPI - :return: JSON with ``status`` set to ``"deleted"``. - :rtype: JSONResponse - """ - try: - contact_points = await pmm_api.list_contact_points() - pd_cp = find_pagerduty_contact_point(contact_points) - if pd_cp is None: - return JSONResponse( - {"error": "PagerDuty contact point not found"}, - status_code=status.HTTP_404_NOT_FOUND, - ) - - policy = await pmm_api.get_notification_policy() - policy.routes = [ - r - for r in policy.routes - if r.get("receiver") != PAGERDUTY_CONTACT_POINT_NAME - ] - await pmm_api.update_notification_policy(policy) - - await pmm_api.delete_contact_point(pd_cp.uid) - - return JSONResponse({"status": "deleted"}) - except (HTTPException, OSError): - logger.exception("Failed to delete PagerDuty contact point") - return JSONResponse( - {"error": "Failed to delete PagerDuty configuration"}, - status_code=status.HTTP_502_BAD_GATEWAY, - ) - - -@router.post( - "/push", dependencies=[IsAuthenticated, IsCsrfValidated], include_in_schema=False -) -async def alerts_push( - pmm_api: RequiredPMMAPIDep, - alert_templates: AlertTemplatesDep, - folder: RequiredAlertFolderDep, - present_names: PMMPresentNamesDep, - selected: Annotated[list[str], Form(alias="selected_templates")], -) -> JSONResponse: - """Push selected alert templates to PMM as rules. - - :param pmm_api: The PMM API client. - :type pmm_api: PMMRemoteAPI - :param alert_templates: Alert templates grouped by service type. - :type alert_templates: AlertTemplatesDep - :param folder: The PMM alert folder. - :type folder: Folder - :param present_names: Set of template names present in PMM, or ``None`` - when PMM is unreachable. - :type present_names: set[str] | None - :param selected: List of template names selected by the user. - :type selected: list[str] - :return: JSON response with per-template push results. - :rtype: JSONResponse - """ - all_templates = {t.name: t for ts in alert_templates.values() for t in ts} - - results = [] - for name in selected: - template = all_templates.get(name) - if template is None: - results.append( - {"name": name, "status": "error", "message": "Template not found"} - ) - continue - - template_exists = present_names is not None and name in present_names - - if template_exists: - try: - await pmm_api.create_rule( - name=template.name, - template_name=template.name, - folder_uid=folder.uid, - for_duration=DEFAULT_FOR_DURATION, - group=alerts_settings.ALERT_FOLDER_NAME, - ) - except (HTTPException, OSError): - logger.debug("Rule already exists for %s", name, exc_info=True) - results.append( - { - "name": name, - "status": "skipped", - "message": "Already present in PMM", - } - ) - continue - - try: - pmm_yaml = to_pmm_template_yaml(template) - await pmm_api.create_template(pmm_yaml) - except (HTTPException, OSError) as exc: - detail = getattr(exc, "detail", str(exc)) - results.append({"name": name, "status": "error", "message": detail}) - continue - - try: - await pmm_api.create_rule( - name=template.name, - template_name=template.name, - folder_uid=folder.uid, - for_duration=DEFAULT_FOR_DURATION, - group=alerts_settings.ALERT_FOLDER_NAME, - ) - results.append( - {"name": name, "status": "success", "message": "Pushed successfully"} - ) - except (HTTPException, OSError) as exc: - detail = getattr(exc, "detail", str(exc)) - if "conflicts with existing" not in detail: - results.append( - { - "name": name, - "status": "error", - "message": f"Template created but rule failed: {detail}", - } - ) - continue - try: - await delete_conflicting_rules(pmm_api, template.name, folder.uid) - await pmm_api.create_rule( - name=template.name, - template_name=template.name, - folder_uid=folder.uid, - for_duration=DEFAULT_FOR_DURATION, - group=alerts_settings.ALERT_FOLDER_NAME, - ) - results.append( - { - "name": name, - "status": "success", - "message": "Pushed successfully (replaced conflicting rule)", - } - ) - except (HTTPException, OSError) as retry_exc: - retry_detail = getattr(retry_exc, "detail", str(retry_exc)) - results.append( - { - "name": name, - "status": "error", - "message": f"Template created but rule failed: {retry_detail}", - } - ) - - return JSONResponse({"results": results}) diff --git a/app/sep/apps/alters/app.py b/app/sep/apps/alters/app.py index 781ff6b2aa..513c5b6b48 100644 --- a/app/sep/apps/alters/app.py +++ b/app/sep/apps/alters/app.py @@ -26,7 +26,7 @@ greedy derived detail so the custom ``GET /{task_name}`` wins. The schema is the model-first :data:`~app.sep.apps.alters.schema.alters_schema` passed through verbatim, so its ``display_name`` stays ``"Alters"`` — distinct from the navigation -label ``"Schema Change"`` carried here. The Jinja UI router is threaded explicitly. +label ``"Schema Change"`` carried here. """ from app.core.pagination import DEFAULT_PAGINATION_LIMIT @@ -35,7 +35,6 @@ from app.sep.apps.alters.api_routes import router as alters_custom_router from app.sep.apps.alters.deps import build_alters_api_list_response, get_alters_task from app.sep.apps.alters.models import AltersTaskResponse, OWNER -from app.sep.apps.alters.routes import router as jinja_router from app.sep.apps.alters.schema import alters_schema from app.sep.apps.framework.apps import ( AppCapabilities, @@ -65,5 +64,4 @@ create=False, detail=False, execute=False, update=False, delete=False ), extra_routes=(alters_custom_router,), - jinja_router=jinja_router, ) diff --git a/app/sep/apps/alters/deps.py b/app/sep/apps/alters/deps.py index 126d2b7bff..8b32fcebc6 100644 --- a/app/sep/apps/alters/deps.py +++ b/app/sep/apps/alters/deps.py @@ -23,15 +23,9 @@ from typing import Annotated, Any from fastapi import Depends -from pydantic import BaseModel, ValidationError -from app.core.exceptions import ( - HTTPBadRequestException, - HTTPConflictException, - HTTPUnprocessableEntityException, -) +from app.core.exceptions import HTTPBadRequestException, HTTPConflictException from app.core.requests.remote_api import RemoteAPI -from app.core.utils.fields import EmptyStrToNone from app.core.utils.path import payload_uri from app.inventory.models import ServiceTypeEnum from app.sep.apps.alters.models import ( @@ -68,9 +62,6 @@ ) from app.sep.deps import ( check_for_conflicted_running_tasks, - DefaultContext, - ExecutorHostsCtx, - get_tasks_context, get_username_mapping, InventoryAPI, reject_if_protected, @@ -85,117 +76,6 @@ logger = logging.getLogger(__name__) -class AltersLegacyForm(BaseModel): - """Parse the deprecated Alters HTML form's flat, urlencoded body. - - The Jinja create/edit templates submit the historical split target fields - (``schema_id`` / ``schema_name`` / ``table_id`` / ``table_name``) that - predate the free-solo collapse, so the derived ``AltersCreate`` cannot bind - them directly. This model parses that flat body; :func:`map_alters_legacy_form` - folds it into ``AltersCreate`` for the shared task builder, keeping the legacy - path byte-identical. Field validation is enforced by the mapped - ``AltersCreate``, not here. - - :param task_name: The task name. - :param hostname: The executor host. - :param service_id: The source MySQL service id. - :param schema_id: The schema inventory id, or empty. - :param schema_name: The manually-entered schema name. - :param table_id: The table inventory id, or empty. - :param table_name: The manually-entered table name. - :param pre_checks_mysql_config_file: The MySQL defaults file path. - :param alter: The pt-osc alter clause. - :param recursion_method: The pt-osc recursion method. - :param dsn_table: The DSN table (recursion method ``dsn``). - :param print_arg: The ``--print`` flag. - :param progress: The ``--progress`` value. - :param no_swap_tables: The ``--no-swap-tables`` flag. - :param no_drop_old_table: The ``--no-drop-old-table`` flag. - :param no_drop_new_table: The ``--no-drop-new-table`` flag. - :param no_drop_triggers: The ``--no-drop-triggers`` flag. - :param pause_file: The ``--pause-file`` path. - :param new_table_name: The ``--new-table-name`` value. - :param tries: The ``--tries`` value. - :param set_vars: The ``--set-vars`` value. - :param critical_load: The ``--critical-load`` value. - :param max_load: The ``--max-load`` value. - :param chunk_time: The ``--chunk-time`` value. - :param max_lag: The ``--max-lag`` value. - :param max_flow_ctl: The ``--max-flow-ctl`` value. - :param extra_args: Additional pt-osc CLI arguments. - :param continue_on_pre_check_failure: The continue-on-pre-check-failure toggle. - :param alert_on_fail: Whether to alert on failure. - """ - - task_name: str - hostname: str - service_id: int - schema_id: int | EmptyStrToNone = None - schema_name: str = "" - table_id: int | EmptyStrToNone = None - table_name: str = "" - pre_checks_mysql_config_file: str = "~/.my.cnf" - alter: str = "" - recursion_method: str = "processlist" - dsn_table: str = "" - print_arg: bool = False - progress: str = "" - no_swap_tables: bool = False - no_drop_old_table: bool = False - no_drop_new_table: bool = False - no_drop_triggers: bool = False - pause_file: str | None = None - new_table_name: str | None = None - tries: str | None = None - set_vars: str | None = None - critical_load: str | None = None - max_load: str | None = None - chunk_time: str | None = None - max_lag: str | None = None - max_flow_ctl: str | None = None - extra_args: str | None = None - continue_on_pre_check_failure: bool = False - alert_on_fail: bool = False - - -def _collapse(ref_id: int | None, name: str) -> int | str | None: - """Collapse a legacy id/name pair into the single free-solo value. - - :param ref_id: The inventory id, or ``None`` when not selected. - :param name: The manually-entered name (``""`` when not entered). - :return: The id when present, else the trimmed name, else ``None``. - """ - if ref_id is not None: - return ref_id - return name.strip() or None - - -def map_alters_legacy_form(flat: AltersLegacyForm) -> AltersCreate: - """Fold the flat legacy form into the collapsed ``AltersCreate``. - - Collapses the split ``schema_id`` / ``schema_name`` and ``table_id`` / - ``table_name`` pairs into the single free-solo ``db_schema`` / ``db_table`` - fields and validates through ``AltersCreate`` so the legacy path enforces the - same rules as the JSON path. A validation failure surfaces as a 422, matching - the framework's body-validation behaviour. - - :param flat: The parsed legacy form body. - :return: The validated collapsed create model. - :raises HTTPUnprocessableEntityException: When the folded model is invalid. - """ - data = { - **flat.model_dump( - exclude={"schema_id", "schema_name", "table_id", "table_name"} - ), - "db_schema": _collapse(flat.schema_id, flat.schema_name), - "db_table": _collapse(flat.table_id, flat.table_name), - } - try: - return AltersCreate.model_validate(data) - except ValidationError as exc: - raise HTTPUnprocessableEntityException(detail=exc.errors()) from exc - - async def build_alters_task( body: AltersCreate, inventory_api: InventoryAPI, @@ -230,26 +110,6 @@ async def build_alters_task( AltersTask = Annotated[Task, Depends(get_alters_task)] -def get_alters_task_info(task: dict[str, Any]) -> dict[str, Any]: - """Extract relevant information from a task for the Alters plugin. - - Processes the task data to extract hostname and table information. - - :param task: The task data retrieved from the Tasks API. - :type task: dict[str, Any] - :return: A dictionary containing hostname and table information. - :rtype: dict[str, Any] - """ - data = task["data"] - meta = data["meta"] - - return { - "hostname": meta["target"], - "table": f"{meta['_schema_name']}.{meta['_table_name']}", - "parent": data.get("parent"), - } - - def extract_service_info(meta: dict[str, Any]) -> dict[str, Any]: """Extract service information from task configuration. @@ -767,9 +627,6 @@ async def get_unprotected_alters_task( return reject_if_protected(parent_task) -UnprotectedAltersTask = Annotated[Task, Depends(get_unprotected_alters_task)] - - async def get_editable_alters_parent_task( task_name: str, tasks_api: TaskAPI, @@ -940,41 +797,3 @@ def build_alters_api_list_response( last_executed_at=last_executed_at, username_mapping=context, ) - - -async def get_alters_index_context( - inventory_api: InventoryAPI, - tasks_api: TaskAPI, - context: DefaultContext, - executor_hosts_ctx: ExecutorHostsCtx, -) -> dict[str, Any]: - """Assemble the context for the Alters plugin index view. - - Retrieves MySQL services and associated tasks, organizing them based on their - execution status. Integrates this information into the default context for - rendering in templates. - - :param inventory_api: The Inventory API client for fetching service and schema data. - :type inventory_api: InventoryAPI - :param tasks_api: The TaskAPI client for fetching task data. - :type tasks_api: TaskAPI - :param context: The default context to be updated with Alters-specific information. - :type context: DefaultContext - :param executor_hosts_ctx: The executor hosts context for the Alters tasks. - :type executor_hosts_ctx: ExecutorHostsCtx - :return: An updated context dictionary containing Alters-related data. - :rtype: dict[str, Any] - """ - return await get_tasks_context( - inventory_api, - tasks_api, - get_alters_task_info, - executor_hosts_ctx, - context, - OWNER, - service_type=ServiceTypeEnum.MYSQL, - alert_on_fail_default=True, - ) - - -AltersIndexContext = Annotated[dict[str, Any], Depends(get_alters_index_context)] diff --git a/app/sep/apps/alters/routes.py b/app/sep/apps/alters/routes.py deleted file mode 100644 index 680a2af459..0000000000 --- a/app/sep/apps/alters/routes.py +++ /dev/null @@ -1,325 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define routes for the alters plugin.""" - -import logging -from typing import Annotated - -from fastapi import APIRouter, Form, HTTPException, Request, status -from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse -from pydantic import FutureDatetime - -from app.core.alerts.config import alert_settings -from app.core.pagination import fetch_all_dict_items -from app.inventory.models import ServiceTypeEnum -from app.sep.apps.alters.deps import ( - alters_executor_matches_service_host, - AltersIndexContext, - AltersLegacyForm, - AltersTask, - build_alters_task, - build_pre_checks_task_payload, - cascade_create_alters_group, - cascade_delete_alters_group, - cascade_update_alters_group, - DeletableAltersParent, - extract_service_info, - map_alters_legacy_form, - parse_alters_task_args, - UnprotectedAltersTask, -) -from app.sep.apps.framework.deprecation import DeprecatedJinja2Route -from app.sep.config import sep_settings -from app.sep.connectivity import maybe_check_connectivity -from app.sep.deps import ( - CheckConnectivityFlag, - DefaultContext, - ExecutorHostsCtx, - get_chainable_tasks, - HasNoConflictedRunningTasks, - InventoryAPI, - IsAuthenticated, - IsCsrfValidated, - TaskAPI, -) -from app.sep.utils.decorators import csrf_exempt -from app.sep.utils.jinja import syntax_highlight -from app.tasks.models import TaskHistoryStatusEnum - -logger = logging.getLogger(__name__) -router = APIRouter(route_class=DeprecatedJinja2Route) -templates = sep_settings.TEMPLATES - - -@router.get("/", dependencies=[IsAuthenticated], response_class=HTMLResponse) -async def alters_index( - request: Request, - context: AltersIndexContext, -) -> HTMLResponse: - """Homepage of alters plugin.""" - return templates.TemplateResponse( - request=request, - name="alters/index.html.j2", - context=context, - ) - - -@router.get("/table/{table_id}/details", dependencies=[IsAuthenticated]) -@csrf_exempt -async def get_table_details( - request: Request, # noqa: ARG001 - table_id: int, - inventory_api: InventoryAPI, - syntax_highlight_style: str | None = None, -) -> JSONResponse: - """Get table details including create statement and keys.""" - try: - table = await inventory_api.get(f"/tables/{table_id}") - create = table["create"] - if syntax_highlight_style: - create = syntax_highlight(create, "sql", style=syntax_highlight_style) - return JSONResponse( - { - "id": table["id"], - "name": table["name"], - "create": create, - "keys": table["keys"], - } - ) - except HTTPException: - return JSONResponse( - {"error": "Failed to fetch table details"}, - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - - -@router.post( - "/", dependencies=[IsAuthenticated, IsCsrfValidated], response_class=HTMLResponse -) -async def alters_create( - request: Request, - form: Annotated[AltersLegacyForm, Form()], - task_api: TaskAPI, - inventory_api: InventoryAPI, - *, - check_connectivity: CheckConnectivityFlag, -) -> RedirectResponse: - """Create the alters task group (parent, dry-run, and pre-checks tasks).""" - logger.debug("Create alters tasks: %s", form.task_name) - create = map_alters_legacy_form(form) - parent_task = await build_alters_task(create, inventory_api) - pre_checks_template = await build_pre_checks_task_payload( - parent_task, task_api=task_api - ) - await cascade_create_alters_group( - task_api, - parent_task, - pre_checks_template, - create, - ) - - await maybe_check_connectivity( - request, - task_api, - parent_task.data.get("meta", {}), - check_connectivity=check_connectivity, - ) - - task_path = request.url_for("alters_detail", task_name=parent_task.name) - return RedirectResponse( - task_path, - status_code=status.HTTP_303_SEE_OTHER, - ) # TODO: Custom redirect class # noqa: TD002, TD003 - - -@router.get("/{task_name}", dependencies=[IsAuthenticated], response_class=HTMLResponse) -async def alters_detail( - task: AltersTask, - request: Request, - context: DefaultContext, - tasks_api: TaskAPI, - inventory_api: InventoryAPI, - executor_hosts_ctx: ExecutorHostsCtx, -) -> HTMLResponse: - """Retrieve alters task.""" - data = task.data - - # If the task has a parent, redirect to the parent task detail page - if data.get("parent"): - task_path = request.url_for("alters_detail", task_name=data["parent"]) - return RedirectResponse(task_path, status_code=status.HTTP_303_SEE_OTHER) - - meta = data["meta"] - decoded_entities = task.anonymized_entities - task_data = { - "name": task.name, - "created_at": task.created_at, - "updated_at": task.updated_at, - "created_by": task.created_by, - "last_updated_by": task.last_updated_by, - "hostname": meta["target"], - "table": f"{meta['_schema_name']}.{meta['_table_name']}", - "cmd": f"{meta['command']} {meta['args']}", - "meta": meta, - "entities": {entity.name: entity.value for entity in decoded_entities}, - "delete_url": request.url_for("alters_delete", task_name=task.name), - "dry_run_url": request.url_for( - "alters_execute", task_name=task.name + "-dry-run" - ), - "pre_checks_url": request.url_for( - "alters_execute", task_name=task.name + "-pre-checks" - ), - "alert_on_fail": task.alert_on_fail, - } - task_data.update(extract_service_info(meta)) - task_data["pre_checks_mysql_config_file"] = meta.get( - "_pre_checks_mysql_config_file", "~/.my.cnf" - ) - - context["task"] = task_data - # TODO(yan): Refactor/reuse like with get_tasks_context # noqa: TD003 - response = await tasks_api.get(f"/{task.name}/history/") - context["history"] = response["items"] - response = await tasks_api.get(f"/{task.name}-dry-run/history/") - context["history_dry_run"] = response["items"] - response = await tasks_api.get(f"/{task.name}-pre-checks/history/") - context["history_pre_checks"] = response["items"] - response = await tasks_api.get( - f"/{task.name}/history/", params={"status": TaskHistoryStatusEnum.RUNNING} - ) - context["running_tasks"] = response["items"] - response = await tasks_api.get( - f"/{task.name}-dry-run/history/", - params={"status": TaskHistoryStatusEnum.RUNNING}, - ) - context["running_tasks"] += response["items"] - response = await tasks_api.get( - f"/{task.name}-pre-checks/history/", - params={"status": TaskHistoryStatusEnum.RUNNING}, - ) - context["running_tasks"] += response["items"] - context["stats"] = await tasks_api.get(f"/stats/{task.name}") - - task_data.update(parse_alters_task_args(meta)) - - try: - services = await fetch_all_dict_items( - lambda pagination: inventory_api.get( - "/services/", - params={ - "service_type": ServiceTypeEnum.MYSQL, - **pagination.model_dump(), - }, - ) - ) - except HTTPException as exc: - services = [] - logger.warning("Failed to get services: %s", exc) - - context["executor_hosts"] = executor_hosts_ctx.with_host( - task_data["hostname"] - ).as_template_list() - context["has_matching_executor"] = alters_executor_matches_service_host( - meta, executor_hosts_ctx.hosts - ) - context["services"] = services - context["alert_on_fail_default"] = task_data["alert_on_fail"] - context["alert_on_fail_available"] = bool(alert_settings.PROVIDERS) - context["chainable_tasks"] = await get_chainable_tasks( - tasks_api, task.owner, meta["target"], task.name - ) - - return templates.TemplateResponse( - request=request, - name="alters/details.html.j2", - context=context, - ) - - -@router.post( - "/{task_name}", - dependencies=[IsAuthenticated, IsCsrfValidated, HasNoConflictedRunningTasks], - response_class=RedirectResponse, -) -async def alters_execute( - request: Request, - task: AltersTask, - tasks_api: TaskAPI, - eta: Annotated[FutureDatetime | None, Form()] = None, - chain_task_names: Annotated[list[str] | None, Form()] = None, - chain_on_failure: Annotated[bool | None, Form()] = None, -) -> RedirectResponse: - """Execute alters task.""" - await tasks_api.post( - f"/execute/{task.name}", - json={ - "eta": eta, - "chain_task_names": chain_task_names, - "chain_on_failure": chain_on_failure, - }, - ) - parent_name = task.data.get("parent") or task.name - task_path = request.url_for("alters_detail", task_name=parent_name) - return RedirectResponse(task_path, status_code=status.HTTP_303_SEE_OTHER) - - -@router.post( - "/{task_name}/update", - dependencies=[IsAuthenticated, IsCsrfValidated], - response_class=RedirectResponse, -) -async def alters_update( - request: Request, - task: UnprotectedAltersTask, - form: Annotated[AltersLegacyForm, Form()], - tasks_api: TaskAPI, - inventory_api: InventoryAPI, -) -> RedirectResponse: - """Update the alters task group.""" - logger.debug("Updating alters task: %s", form.task_name) - create = map_alters_legacy_form(form) - updated_parent = await build_alters_task(create, inventory_api) - pre_checks_template = await build_pre_checks_task_payload( - updated_parent, task_api=tasks_api - ) - result = await cascade_update_alters_group( - tasks_api, - task.name, - updated_parent, - pre_checks_template, - create, - ) - result.raise_if_failed(op="update") - - return RedirectResponse( - request.url_for("alters_detail", task_name=updated_parent.name), - status_code=status.HTTP_303_SEE_OTHER, - ) - - -@router.post( - "/{task_name}/delete", - dependencies=[IsAuthenticated, IsCsrfValidated], - response_class=RedirectResponse, -) -async def alters_delete( - parent_task: DeletableAltersParent, - tasks_api: TaskAPI, -) -> RedirectResponse: - """Delete the alters task group.""" - result = await cascade_delete_alters_group(tasks_api, parent_task.name) - result.raise_if_failed(op="delete") - return RedirectResponse("/alters", status_code=status.HTTP_303_SEE_OTHER) diff --git a/app/sep/apps/archives/app.py b/app/sep/apps/archives/app.py index 62c25619d5..6549286861 100644 --- a/app/sep/apps/archives/app.py +++ b/app/sep/apps/archives/app.py @@ -22,15 +22,12 @@ :class:`~app.sep.apps.archives.models.ArchivesCreate` through the ``run-python`` pt-archiver spec builder; the connectivity probe and the source service selection ride on the source ``ServiceRef(check_connectivity=True)``, and -the archiver failure-alert builder is stamped via ``alert_detail_builder``. The -deprecated Jinja UI router is threaded as ``jinja_router``; its flat form body is -folded into the one-of model in ``deps``. +the archiver failure-alert builder is stamped via ``alert_detail_builder``. """ from app.inventory.models import ServiceTypeEnum from app.sep.apps.archives.alerts import ALERT_DETAIL_BUILDER from app.sep.apps.archives.models import ArchivesCreate, OWNER -from app.sep.apps.archives.routes import router as jinja_router from app.sep.apps.archives.spec import build_archives_spec from app.sep.apps.archives.views import archives_views from app.sep.apps.framework.apps import ( @@ -56,5 +53,4 @@ capabilities=AppCapabilities(update=True, delete=True), service_type=ServiceTypeEnum.MYSQL, list_filter=ListFilterConfig(status=True), - jinja_router=jinja_router, ) diff --git a/app/sep/apps/archives/deps.py b/app/sep/apps/archives/deps.py deleted file mode 100644 index 34c7e6190d..0000000000 --- a/app/sep/apps/archives/deps.py +++ /dev/null @@ -1,302 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define dependencies for the Archives plugin. - -The model-first JSON create / update / list / detail surfaces are derived by the -``TaskExecutionApp`` in ``app.py``. This module holds the parts that stay -hand-written: the legacy Jinja form path (a flat parse model mapped into the -one-of ``ArchivesCreate`` so the deprecated HTML form keeps working against the -shared spec builder), the list/detail response builder, the task-by-name -dependency, and the Jinja index context. -""" - -import logging -from datetime import date -from typing import Annotated, Any - -from fastapi import Depends, Form -from pydantic import Field, ValidationError - -from app.core.exceptions import HTTPUnprocessableEntityException -from app.core.models import BaseCaseInsensitiveModel -from app.core.utils.fields import EmptyStrToNone, NonEmptyStr, TcpPort -from app.inventory.models import ServiceTypeEnum -from app.sep.apps.archives.alerts import ( - ALERT_DETAIL_BUILDER, - parse_archiver_purge_config, -) -from app.sep.apps.archives.models import ArchivesCreate, OWNER -from app.sep.apps.archives.spec import build_archives_spec -from app.sep.apps.framework import make_task_dep -from app.sep.apps.framework.spec import assemble_envelope, resolve_refs -from app.sep.deps import ( - DefaultContext, - ExecutorHostsCtx, - get_tasks_context, - InventoryAPI, - TaskAPI, -) -from app.tasks.models import Task, TaskWrite - -logger = logging.getLogger(__name__) - - -class ArchivesLegacyForm(BaseCaseInsensitiveModel): - """Parse the deprecated Archives HTML form's flat, urlencoded body. - - The Jinja templates submit the historical flat field names (``source_db_id`` / - ``source_db_name`` / …) that predate the one-of collapse, so the derived - one-of ``ArchivesCreate`` cannot bind them directly. This model parses that - flat body; :func:`_map_legacy_to_create` folds it into ``ArchivesCreate`` for - the shared spec builder, keeping the legacy path byte-identical. Conditional - validation is enforced by the mapped ``ArchivesCreate``, not here. - - :param alias: The task name (the ``ALIAS`` in the archiver config). - :param hostname: The executor host. - :param service_id: The source MySQL service id. - :param source_db_id: The source schema inventory id, or empty. - :param source_db_name: The manually-entered source schema name. - :param source_table_id: The source table inventory id, or empty. - :param source_table_name: The manually-entered source table name. - :param source_query: A custom source query. - :param where: The WHERE clause. - :param dest_table_id: The destination table inventory id, or empty. - :param dest_table_name: The manually-entered destination table name. - :param dest_file: The destination file path. - :param swap_drop: The archive type (0-2). - :param swp_table_suffix: The swap-table date suffix. - :param use_index: An index hint. - :param extra_args: Additional pt-archiver CLI arguments. - :param limit: The maximum rows per run. - :param sleep: The sleep between chunk operations. - :param disable_binlog: The disable-binlog flag (0/1). - :param disable_bulk_insert: The disable-bulk-insert flag (0/1). - :param delete_data: The delete-without-archiving flag (0/1). - :param dest_service_id: The destination service inventory id, or empty. - :param dest_host: The manual destination host. - :param dest_port: The manual destination port. - :param dest_db_id: The destination schema inventory id, or empty. - :param dest_db_name: The manually-entered destination schema name. - :param alert_on_fail: Whether to alert on failure. - """ - - alias: NonEmptyStr - hostname: NonEmptyStr - service_id: int - source_db_id: int | EmptyStrToNone = None - source_db_name: str = "" - source_table_id: int | EmptyStrToNone = None - source_table_name: str = "" - source_query: NonEmptyStr | None = None - where: NonEmptyStr | None = None - dest_table_id: int | EmptyStrToNone = None - dest_table_name: str = "" - dest_file: NonEmptyStr | None = None - swap_drop: int = Field(..., ge=0, le=2) - swp_table_suffix: date | None = None - use_index: NonEmptyStr | None = None - extra_args: NonEmptyStr | None = None - limit: int | EmptyStrToNone = None - sleep: int | EmptyStrToNone = None - disable_binlog: int | None = Field(None, ge=0, le=1) - disable_bulk_insert: int | None = Field(None, ge=0, le=1) - delete_data: int | None = Field(None, ge=0, le=1) - dest_service_id: int | EmptyStrToNone = None - dest_host: str | None = None - dest_port: TcpPort | EmptyStrToNone = None - dest_db_id: int | EmptyStrToNone = None - dest_db_name: str = "" - alert_on_fail: bool = False - - -def _int_flag_to_bool(value: int | None) -> bool | None: - """Map a legacy integer flag (0/1/None) to the one-of model's tri-state bool. - - :param value: The legacy integer flag (``0`` / ``1`` / ``None``). - :return: ``None`` when unset, else the boolean. - """ - return None if value is None else bool(value) - - -def _collapse(ref_id: int | None, name: str) -> int | str | None: - """Collapse a legacy id/name pair into the one-of's single free-solo value. - - :param ref_id: The inventory id, or ``None`` when not selected. - :param name: The manually-entered name (``""`` when not entered). - :return: The id when present, else the trimmed name, else ``None``. - """ - if ref_id is not None: - return ref_id - return name.strip() or None - - -def _map_legacy_to_create(flat: ArchivesLegacyForm) -> ArchivesCreate: - """Fold the flat legacy form into the one-of ``ArchivesCreate``. - - Builds the discriminated-union branches from the flat id/name pairs and - validates through ``ArchivesCreate`` so the legacy path enforces the same - conditional rules as the JSON path. A validation failure surfaces as a 422, - matching the framework's body-validation behaviour. - - :param flat: The parsed legacy form body. - :return: The validated one-of create model. - :raises HTTPUnprocessableEntityException: When the folded model is invalid. - """ - data: dict[str, Any] = { - "task_name": flat.alias, - "hostname": flat.hostname, - "service_id": flat.service_id, - "swap_drop": flat.swap_drop, - "swp_table_suffix": flat.swp_table_suffix, - "where": flat.where, - "use_index": flat.use_index, - "extra_args": flat.extra_args, - "limit": flat.limit, - "sleep": flat.sleep, - "disable_binlog": _int_flag_to_bool(flat.disable_binlog), - "disable_bulk_insert": _int_flag_to_bool(flat.disable_bulk_insert), - "delete_data": _int_flag_to_bool(flat.delete_data), - "alert_on_fail": flat.alert_on_fail, - } - if flat.source_query: - data["source"] = {"mode": "query", "source_query": flat.source_query} - else: - data["source"] = { - "mode": "table", - "source_db": _collapse(flat.source_db_id, flat.source_db_name), - "source_table": _collapse(flat.source_table_id, flat.source_table_name), - } - if flat.dest_file: - data["destination"] = {"mode": "file", "dest_file": flat.dest_file} - elif flat.dest_table_id is not None or flat.dest_table_name.strip(): - data["destination"] = { - "mode": "table", - "dest_db": _collapse(flat.dest_db_id, flat.dest_db_name), - "dest_table": _collapse(flat.dest_table_id, flat.dest_table_name), - } - if flat.dest_service_id is not None: - data["host"] = {"mode": "service", "dest_service": flat.dest_service_id} - elif flat.dest_host and flat.dest_host.strip(): - data["host"] = { - "mode": "manual", - "dest_host": flat.dest_host, - "dest_port": flat.dest_port, - } - try: - return ArchivesCreate.model_validate(data) - except ValidationError as exc: - raise HTTPUnprocessableEntityException(detail=exc.errors()) from exc - - -async def build_archives_task_payload( - form: Annotated[ArchivesLegacyForm, Form()], - inventory_api: InventoryAPI, -) -> TaskWrite: - """Build the archive task payload from the legacy HTML form body (Jinja2 path). - - Folds the flat form into the one-of ``ArchivesCreate`` and feeds the shared - pure :func:`~app.sep.apps.archives.spec.build_archives_spec` through the - framework's ``assemble_envelope`` — the same pair the model-first JSON create - route uses — so a form-created task's payload stays byte-identical. - - :param form: The parsed legacy form body. - :param inventory_api: The Inventory API to resolve the reference fields. - :return: A fully constructed ``TaskWrite`` object. - """ - create = _map_legacy_to_create(form) - resolved = await resolve_refs(create, inventory_api) - return assemble_envelope( - build_archives_spec(create, resolved), - resolved, - name=create.task_name, - owner=OWNER, - alert_on_fail=create.alert_on_fail, - alert_detail_builder=ALERT_DETAIL_BUILDER, - ) - - -ArchivesGeneratedTask = Annotated[TaskWrite, Depends(build_archives_task_payload)] - - -get_archives_task = make_task_dep(OWNER) - -ArchivesTask = Annotated[Task, Depends(get_archives_task)] - - -def get_archives_task_info(task: dict[str, Any]) -> dict[str, Any]: - """Extract relevant information from a task for the Archives plugin. - - Processes the task data to extract hostname and tables information. - - :param task: The task data retrieved from the Tasks API. - :return: A dictionary containing hostname and tables information. - """ - data = task["data"] - meta = data["meta"] - # Shared parser (single source of truth for the PURGE_LIST field mapping); - # the tasks-service failure-alert builder uses the same function. - fields = parse_archiver_purge_config(meta["config"]) - - result = { - "hostname": meta["target"], - "created_by": task.get("created_by"), - "last_updated_by": task.get("last_updated_by"), - } - - if fields: - if fields.source: - result["source_table"] = fields.source - if fields.dest_table_display: - result["dest_table"] = fields.dest_table_display - if fields.source_query: - result["source_query"] = fields.source_query - if fields.dest_file: - result["dest_file"] = fields.dest_file - - return result - - -async def get_archives_index_context( - inventory_api: InventoryAPI, - tasks_api: TaskAPI, - context: DefaultContext, - executor_hosts_ctx: ExecutorHostsCtx, -) -> dict[str, Any]: - """Assemble the context for the Archives plugin index view. - - Retrieves MySQL services and associated tasks, organizing them based on their - execution status. Integrates this information into the default context for - rendering in templates. - - :param inventory_api: The Inventory API client for service and schema data. - :param tasks_api: The TaskAPI client for fetching task data. - :param context: The default context to update with Archives-specific data. - :param executor_hosts_ctx: The executor hosts context for the Archives tasks. - :return: An updated context dictionary containing Archives-related data. - """ - return await get_tasks_context( - inventory_api, - tasks_api, - get_archives_task_info, - executor_hosts_ctx, - context, - OWNER, - service_type=ServiceTypeEnum.MYSQL, - alert_on_fail_default=True, - ) - - -ArchivesIndexContext = Annotated[dict[str, Any], Depends(get_archives_index_context)] diff --git a/app/sep/apps/archives/routes.py b/app/sep/apps/archives/routes.py deleted file mode 100644 index ab6cabed7f..0000000000 --- a/app/sep/apps/archives/routes.py +++ /dev/null @@ -1,255 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define routes for the archivers plugin.""" - -import logging -from typing import Annotated - -import yaml -from fastapi import APIRouter, Form, Request, status -from fastapi.responses import HTMLResponse, RedirectResponse -from pydantic import FutureDatetime - -from app.core.alerts.config import alert_settings -from app.core.pagination import fetch_all_dict_items -from app.inventory.models import ServiceTypeEnum -from app.sep.apps.archives.deps import ( - ArchivesGeneratedTask, - ArchivesIndexContext, - ArchivesTask, -) -from app.sep.apps.archives.models import PurgeConfigItem -from app.sep.apps.framework.deprecation import DeprecatedJinja2Route -from app.sep.config import sep_settings -from app.sep.connectivity import maybe_check_connectivity -from app.sep.deps import ( - CheckConnectivityFlag, - DefaultContext, - ExecutorHostsCtx, - get_chainable_tasks, - HasNoConflictedRunningTasks, - InventoryAPI, - IsAuthenticated, - IsCsrfValidated, - TaskAPI, -) -from app.tasks.models import TaskHistoryStatusEnum - -logger = logging.getLogger(__name__) -router = APIRouter(route_class=DeprecatedJinja2Route) -templates = sep_settings.TEMPLATES - - -@router.get("/", dependencies=[IsAuthenticated], response_class=HTMLResponse) -async def archives_index( - request: Request, - context: ArchivesIndexContext, -) -> HTMLResponse: - """Homepage of archives plugin.""" - return templates.TemplateResponse( - request=request, - name="archiver/index.html.j2", - context=context, - ) - - -@router.post( - "/", dependencies=[IsAuthenticated, IsCsrfValidated], response_class=HTMLResponse -) -async def archives_create( - request: Request, - task: ArchivesGeneratedTask, - task_api: TaskAPI, - *, - check_connectivity: CheckConnectivityFlag, -) -> RedirectResponse: - """Create new archives task.""" - logger.debug("Create archives task: %s", task) - await task_api.post( - "/", - json=task.model_dump(), - ) - await maybe_check_connectivity( - request, - task_api, - task.data.get("meta", {}), - check_connectivity=check_connectivity, - ) - task_path = request.url_for("archives_detail", task_name=task.name) - return RedirectResponse( - task_path, - status_code=status.HTTP_303_SEE_OTHER, - ) # TODO: Custom redirect class # noqa: TD002, TD003 - - -@router.get("/{task_name}", dependencies=[IsAuthenticated], response_class=HTMLResponse) -async def archives_detail( - task: ArchivesTask, - request: Request, - context: DefaultContext, - inventory_api: InventoryAPI, - tasks_api: TaskAPI, - executor_hosts_ctx: ExecutorHostsCtx, -) -> HTMLResponse: - """Retrieve archives task.""" - data = task.data - meta = data["meta"] - decoded_entities = task.anonymized_entities - task_config = yaml.safe_load(meta["config"]) - all_server = task_config["ALL"] - purge_item = task_config["PURGE_LIST"][0] - task_data = { - "name": task.name, - "created_at": task.created_at, - "updated_at": task.updated_at, - "created_by": task.created_by, - "last_updated_by": task.last_updated_by, - "hostname": meta["target"], - "meta": meta, - "entities": {entity.name: entity.value for entity in decoded_entities}, - "delete_url": request.url_for("archives_delete", task_name=task.name), - "is_edit_enabled": not task.protected, - "alert_on_fail": task.alert_on_fail, - } - - source_db = purge_item.get("SOURCE_DB") - source_table = purge_item.get("SOURCE_TABLE") - dest_table = purge_item.get("DEST_TABLE") - source_query = purge_item.get("SOURCE_QUERY") - dest_file = purge_item.get("DEST_FILE") - dest_host = purge_item.get("DEST_HOST") - dest_db = purge_item.get("DEST_DB") - dest_port = purge_item.get("DEST_PORT") - - if source_db and source_table: - task_data["source_table"] = f"{source_db}.{source_table}" - if dest_table: - display_db = dest_db if dest_db else source_db - if display_db: - task_data["dest_table"] = f"{display_db}.{dest_table}" - else: - task_data["dest_table"] = dest_table - if source_query: - task_data["source_query"] = source_query - if dest_file: - task_data["dest_file"] = dest_file - if dest_host: - task_data["dest_host"] = dest_host - if dest_port is not None: - task_data["dest_port"] = dest_port - if dest_db: - task_data["dest_db"] = dest_db - task_data["source_host"] = all_server.get("SOURCE_HOST") - task_data["source_port"] = all_server.get("SOURCE_PORT") - context["task"] = { - **task_data, - **PurgeConfigItem.model_validate(purge_item).model_dump(), - } - response = await tasks_api.get(f"/{task.name}/history/") - context["history"] = response["items"] - response = await tasks_api.get( - f"/{task.name}/history/", params={"status": TaskHistoryStatusEnum.RUNNING} - ) - context["running_tasks"] = response["items"] - context["stats"] = await tasks_api.get(f"/stats/{task.name}") - - context["services"] = await fetch_all_dict_items( - lambda pagination: inventory_api.get( - "/services/", - params={ - "service_type": ServiceTypeEnum.MYSQL, - **pagination.model_dump(), - }, - ) - ) - - context["executor_hosts"] = executor_hosts_ctx.with_host( - task_data["hostname"] - ).as_template_list() - context["alert_on_fail_default"] = task.alert_on_fail - context["alert_on_fail_available"] = bool(alert_settings.PROVIDERS) - context["chainable_tasks"] = await get_chainable_tasks( - tasks_api, task.owner, meta["target"], task.name - ) - - return templates.TemplateResponse( - request=request, - name="archiver/details.html.j2", - context=context, - ) - - -@router.post( - "/{task_name}", - dependencies=[IsAuthenticated, IsCsrfValidated, HasNoConflictedRunningTasks], - response_class=RedirectResponse, -) -async def archives_execute( - request: Request, - task: ArchivesTask, - tasks_api: TaskAPI, - eta: Annotated[FutureDatetime | None, Form()] = None, - chain_task_names: Annotated[list[str] | None, Form()] = None, - chain_on_failure: Annotated[bool | None, Form()] = None, -) -> RedirectResponse: - """Execute archives task.""" - await tasks_api.post( - f"/execute/{task.name}", - json={ - "eta": eta, - "chain_task_names": chain_task_names, - "chain_on_failure": chain_on_failure, - }, - ) - task_path = request.url_for("archives_detail", task_name=task.name) - return RedirectResponse(task_path, status_code=status.HTTP_303_SEE_OTHER) - - -@router.post( - "/{task_name}/update", - dependencies=[IsAuthenticated, IsCsrfValidated], - response_class=RedirectResponse, -) -async def archives_update( - request: Request, - task_name: str, - updated_task: ArchivesGeneratedTask, - tasks_api: TaskAPI, -) -> RedirectResponse: - """Update archives task.""" - logger.debug("Updating archives task: %s", updated_task) - await tasks_api.put( - f"/{task_name}", - json=updated_task.model_dump(), - ) - return RedirectResponse( - request.url_for("archives_detail", task_name=updated_task.name), - status_code=status.HTTP_303_SEE_OTHER, - ) - - -@router.post( - "/{task_name}/delete", - dependencies=[IsAuthenticated, IsCsrfValidated], - response_class=RedirectResponse, -) -async def archives_delete( - task: ArchivesTask, - tasks_api: TaskAPI, -) -> RedirectResponse: - """Delete archives task.""" - await tasks_api.delete(f"/{task.name}") - return RedirectResponse("/archives", status_code=status.HTTP_303_SEE_OTHER) diff --git a/app/sep/apps/backup_mongo/app.py b/app/sep/apps/backup_mongo/app.py index 7603b0b290..1279398c25 100644 --- a/app/sep/apps/backup_mongo/app.py +++ b/app/sep/apps/backup_mongo/app.py @@ -32,7 +32,6 @@ app. The restore subpackage is a structurally-bound child app declared via ``child_apps`` (mounted at ``/api/apps/backup_mongo/restore/`` with its own derived router), so it is no longer mounted as a ``/restores`` sub-router here. -The Jinja UI router is threaded explicitly. """ from app.sep.apps.backup_mongo.api_routes import router as backup_mongo_custom_router @@ -42,7 +41,6 @@ ) from app.sep.apps.backup_mongo.models import BackupTaskResponse, BackupType, OWNER from app.sep.apps.backup_mongo.restore.app import app as restore_app -from app.sep.apps.backup_mongo.routes import router as jinja_router from app.sep.apps.backup_mongo.schema import backup_mongo_schema from app.sep.apps.framework.apps import ( AppCapabilities, @@ -74,6 +72,5 @@ create=False, detail=False, execute=False, update=False, delete=False ), extra_routes=(backup_mongo_custom_router,), - jinja_router=jinja_router, child_apps=(restore_app,), ) diff --git a/app/sep/apps/backup_mongo/deps.py b/app/sep/apps/backup_mongo/deps.py index 1c88627d0c..8148ec96ae 100644 --- a/app/sep/apps/backup_mongo/deps.py +++ b/app/sep/apps/backup_mongo/deps.py @@ -21,9 +21,8 @@ from datetime import datetime from typing import Annotated, Any -import yaml from aiohttp import ClientResponseError -from fastapi import Depends, Form +from fastapi import Depends from app.core.exceptions import HTTPConflictException, HTTPNotFoundException from app.inventory.models import ServiceTypeEnum @@ -58,10 +57,7 @@ from app.sep.apps.framework.spec import stamp_form_input from app.sep.deps import ( check_group_for_conflicted_running_tasks, - DefaultContext, - ExecutorHostsCtx, get_created_entity, - get_tasks_context, InventoryAPI, reject_if_protected, TaskAPI, @@ -177,27 +173,6 @@ async def build_backup_cascade_plan( BackupCascadePlan = Annotated[CascadeCreatePlan, Depends(build_backup_cascade_plan)] -async def build_backup_task_payload_from_form( - form: Annotated[BackupCreate, Form()], - inventory_api: InventoryAPI, -) -> TaskWrite: - """Build a Backups task payload from an HTML form submission. - - Delegates to :func:`build_backup_task_payload` after FastAPI form parsing. - - :param form: The form data for the Backups creation. - :type form: BackupCreate - :param inventory_api: The Inventory API to get entities from. - :type inventory_api: InventoryAPI - :return: A fully constructed ``TaskWrite`` object for the Tasks API. - :rtype: TaskWrite - """ - return await build_backup_task_payload(form, inventory_api) - - -BackupGeneratedTask = Annotated[TaskWrite, Depends(build_backup_task_payload_from_form)] - - def build_backup_mongo_api_task_response( task: Task, *, @@ -446,9 +421,10 @@ async def update_backup_task_group( Rebuilds the parent ``pbm_config`` payload, re-stamps ``_form`` so the edit page keeps prefilling across repeated edits, backfills any missing derived - siblings (for example ``-incremental`` on pre-SEP-1373 groups), and PUTs the - parent plus its derived logical, physical, status, and incremental legs in - place. Returns the per-leg outcome; the caller raises on partial failure. + siblings (for example ``-incremental`` on groups created before that leg + existed), and PUTs the parent plus its derived logical, physical, status, + and incremental legs in place. Returns the per-leg outcome; the caller + raises on partial failure. :param tasks_api: The TaskAPI instance used to update tasks. :param parent_task: The parent backup config task. @@ -468,66 +444,3 @@ async def update_backup_task_group( backup_derived_task_names(parent_task.name), BACKUP_MONGO_DERIVED, ) - - -def get_backups_task_info(task: dict[str, Any]) -> dict[str, Any]: - """Extract relevant information from a task for the Backups plugin. - - Processes the task data to extract hostname and tables information. - - :param task: The task data retrieved from the Tasks API. - :type task: dict[str, Any] - :return: A dictionary containing hostname and tables information. - :rtype: dict[str, Any] - """ - data = task["data"] - meta = data["meta"] - return { - "config": yaml.safe_load(meta["config"]), - "parent": data.get("parent"), - "target": meta["target"], - "created_at": task["created_at"], - "created_by": task.get("created_by"), - "last_updated_by": task.get("last_updated_by"), - } - - -async def get_backups_index_context( - inventory_api: InventoryAPI, - tasks_api: TaskAPI, - context: DefaultContext, - executor_hosts_ctx: ExecutorHostsCtx, -) -> dict[str, Any]: - """Assemble the context for the Backups plugin index view. - - Retrieves MongoDB services and associated tasks, organizing them based on their - execution status. Integrates this information into the default context for - rendering in templates. - - :param inventory_api: The Inventory API client for fetching service and schema data. - :type inventory_api: InventoryAPI - :param tasks_api: The TaskAPI client for fetching task data. - :type tasks_api: TaskAPI - :param context: The default context to be updated with Backups-specific information. - :type context: DefaultContext - :param executor_hosts_ctx: The executor hosts context for the Backups tasks. - :type executor_hosts_ctx: ExecutorHostsCtx - :return: An updated context dictionary containing Backups-related data. - :rtype: dict[str, Any] - """ - return await get_tasks_context( - inventory_api, - tasks_api, - get_backups_task_info, - executor_hosts_ctx, - context, - OWNER, - service_type=ServiceTypeEnum.MONGODB, - alert_on_fail_default=True, - ) - - -BackupsIndexContextDep = Annotated[ - dict[str, Any], - Depends(get_backups_index_context), -] diff --git a/app/sep/apps/backup_mongo/restore/app.py b/app/sep/apps/backup_mongo/restore/app.py index ac04b92a11..b39a4b58d3 100644 --- a/app/sep/apps/backup_mongo/restore/app.py +++ b/app/sep/apps/backup_mongo/restore/app.py @@ -17,9 +17,8 @@ The registry discovers this app through ``backup_mongo``'s ``child_apps`` rather than a ``settings.yaml`` entry, so it is mounted and toggled exactly with its -parent: its JSON router serves at ``/api/apps/backup_mongo/restore/`` while the -existing Jinja UI keeps serving at ``/backup_mongo/restores/`` via the threaded -``jinja_router``. A restore is a task group (parent config plus restore / pbm-list +parent: its JSON router serves at ``/api/apps/backup_mongo/restore/``. +A restore is a task group (parent config plus restore / pbm-list / optional force-resync legs), so its ``update`` / ``delete`` act on the whole cascade — the framework's single-task derived routes would orphan or stale the legs — leaving only ``schema`` and ``execute`` derived; the union list, the @@ -28,8 +27,7 @@ routes win. Because ``backup_mongo`` is a ``schema=`` app, the sibling-tab ``RelatedApp`` lives on its ``AppSchema`` (in ``backup_mongo/schema.py``), not here. A child has no ``settings.yaml`` entry to stamp its identity, so ``key`` / -``name`` / ``uri_path`` are set explicitly; ``uri_path`` preserves the Jinja route -the ``pbm_restores_*`` handlers build their redirects against. +``name`` / ``uri_path`` are set explicitly. """ from app.sep.apps.backup_mongo.restore.api_routes import router as restore_custom_router @@ -38,7 +36,6 @@ get_restores_task, ) from app.sep.apps.backup_mongo.restore.models import OWNER, RestoreTaskResponse -from app.sep.apps.backup_mongo.restore.routes import router as jinja_router from app.sep.apps.backup_mongo.restore.schema import restore_mongo_schema from app.sep.apps.framework.apps import AppCapabilities, TaskExecutionApp @@ -66,5 +63,4 @@ delete=False, ), extra_routes=(restore_custom_router,), - jinja_router=jinja_router, ) diff --git a/app/sep/apps/backup_mongo/restore/deps.py b/app/sep/apps/backup_mongo/restore/deps.py index 31387e84d2..6548275cf8 100644 --- a/app/sep/apps/backup_mongo/restore/deps.py +++ b/app/sep/apps/backup_mongo/restore/deps.py @@ -21,7 +21,7 @@ from typing import Annotated, Any import yaml -from fastapi import Depends, Form +from fastapi import Depends from app.core.exceptions import HTTPNotFoundException from app.core.pagination import fetch_all_dict_items, PaginatedResponse, Pagination @@ -37,10 +37,7 @@ RestoreTaskResponse, RestoreTaskWrite, ) -from app.sep.apps.backup_mongo.restore.spec import ( - build_force_resync_payload, - build_restore_payloads, -) +from app.sep.apps.backup_mongo.restore.spec import build_restore_payloads from app.sep.apps.framework import ( batch_get_latest_statuses, build_default_task_response, @@ -59,10 +56,7 @@ from app.sep.apps.framework.spec import stamp_form_input from app.sep.deps import ( check_group_for_conflicted_running_tasks, - DefaultContext, - ExecutorHostsCtx, get_created_entity, - get_tasks_context, InventoryAPI, reject_if_protected, TaskAPI, @@ -110,100 +104,6 @@ async def _resolve_service_name( return service.name -async def build_restore_config_task_payload( - form: Annotated[RestoreCreate, Form()], - inventory_api: InventoryAPI, -) -> TaskWrite: - """Build task payload for restore config operation in PBM format.""" - service_name = await _resolve_service_name(form, inventory_api) - return build_restore_payloads(form, service_name).config_task - - -async def build_restore_task_payload( - form: Annotated[RestoreCreate, Form()], - inventory_api: InventoryAPI, -) -> TaskWrite: - """Build task payload for a restore operation in PBM format.""" - service_name = await _resolve_service_name(form, inventory_api) - return build_restore_payloads(form, service_name).restore_task - - -async def build_pbm_list_task_payload( - form: Annotated[RestoreCreate, Form()], - inventory_api: InventoryAPI, -) -> TaskWrite: - """Build task payload for pbm list command.""" - service_name = await _resolve_service_name(form, inventory_api) - return build_restore_payloads(form, service_name).pbm_list_task - - -async def build_pbm_force_resync_task_payload( - form: Annotated[RestoreCreate, Form()], - inventory_api: InventoryAPI, -) -> TaskWrite: - """Build task payload for pbm config --force-resync command (physical restores only).""" - service_name = await _resolve_service_name(form, inventory_api) - return build_force_resync_payload(form, service_name) - - -def _parse_restore_config_options(restore_config: dict[str, Any]) -> dict[str, Any]: - """Parse restore configuration options from task data.""" - result = {} - if "batchSize" in restore_config: - result["restore_batch_size"] = restore_config["batchSize"] - if "numInsertionWorkers" in restore_config: - result["restore_num_insertion_workers"] = restore_config["numInsertionWorkers"] - if "numParallelCollections" in restore_config: - result["restore_num_parallel_collections"] = restore_config[ - "numParallelCollections" - ] - if "numDownloadWorkers" in restore_config: - result["restore_num_download_workers"] = restore_config["numDownloadWorkers"] - if "maxDownloadBufferMb" in restore_config: - result["restore_max_download_buffer_mb"] = restore_config["maxDownloadBufferMb"] - if "downloadChunkMb" in restore_config: - result["restore_download_chunk_mb"] = restore_config["downloadChunkMb"] - if "mongodLocation" in restore_config: - result["restore_mongod_location"] = restore_config["mongodLocation"] - if "mongodLocationMap" in restore_config: - result["restore_mongod_location_map"] = yaml.dump( - restore_config["mongodLocationMap"] - ) - return result - - -def parse_restore_task_data(task: dict[str, Any]) -> dict[str, Any]: - """Parse restore task data for editing. - - Extracts configuration from an existing restore task to populate the edit form. - Reads from PBM format config (lowercase keys, camelCase values). - - :param task: The task data retrieved from the Tasks API. - :type task: dict[str, Any] - :return: A dictionary containing parsed restore configuration. - :rtype: dict[str, Any] - """ - data = task["data"] - meta = data["meta"] - task_config = yaml.safe_load(meta["config"]) - restore_config = task_config.get("restore", {}) - - result = { - "name": task["name"], - "hostname": meta["target"], - "backup_type": task_config.get("backupType"), - "service_id": None, - "backup_source": task_config.get("backupSource"), - "credentials_path": task_config.get("credentials_path"), - } - - # Add restore options - if restore_config: - result.update(_parse_restore_config_options(restore_config)) - - return result - - def restore_create_from_write(body: RestoreTaskWrite) -> RestoreCreate: """Convert a :class:`RestoreTaskWrite` JSON body into :class:`RestoreCreate`. @@ -247,24 +147,6 @@ def restore_update_form_from_write( ) -async def build_restore_update_task_payload( - form: RestoreCreate, - inventory_api: InventoryAPI, -) -> TaskWrite: - """Build the parent restore-config payload for a JSON API update request. - - :param form: The restore update input with ``task_name`` pinned to the - parent config task from the URL path. - :type form: RestoreCreate - :param inventory_api: The Inventory API to look up services. - :type inventory_api: InventoryAPI - :return: A ``TaskWrite`` payload for the parent config task. - :rtype: TaskWrite - """ - service_name = await _resolve_service_name(form, inventory_api) - return build_restore_payloads(form, service_name).config_task - - async def update_restore_task_group( tasks_api: TaskAPI, parent_task: Task, @@ -326,44 +208,6 @@ async def build_restore_task_group( return build_restore_payloads(form, service_name) -async def build_restore_tasks( - form: Annotated[RestoreCreate, Form()], - inventory_api: InventoryAPI, -) -> RestoreTaskGroupPayloads: - """Build restore task group payloads from an HTML form submission. - - Delegates to :func:`build_restore_task_group` after FastAPI form parsing. - - :param form: The restore creation form. - :type form: RestoreCreate - :param inventory_api: The Inventory API to look up services. - :type inventory_api: InventoryAPI - :return: Named payloads for config, restore, pbm-list, and optional force-resync legs. - :rtype: RestoreTaskGroupPayloads - """ - return await build_restore_task_group(form, inventory_api) - - -async def build_restore_task_group_from_body( - body: RestoreTaskWrite, - inventory_api: InventoryAPI, -) -> RestoreTaskGroupPayloads: - """Build restore task group payloads from a JSON API request body. - - Delegates to :func:`build_restore_task_group` after converting the body - to :class:`RestoreCreate`. - - :param body: The JSON request body for restore task creation. - :type body: RestoreTaskWrite - :param inventory_api: The Inventory API to look up services. - :type inventory_api: InventoryAPI - :return: Named payloads for config, restore, pbm-list, and optional force-resync legs. - :rtype: RestoreTaskGroupPayloads - """ - form = restore_create_from_write(body) - return await build_restore_task_group(form, inventory_api) - - async def build_restore_cascade_plan( body: RestoreTaskWrite, inventory_api: InventoryAPI, @@ -752,87 +596,7 @@ async def delete_restore_task_group( result.raise_if_failed(op="delete") -RestoreTasks = Annotated[RestoreTaskGroupPayloads, Depends(build_restore_tasks)] -RestoreTaskGroupFromBody = Annotated[ - RestoreTaskGroupPayloads, - Depends(build_restore_task_group_from_body), -] RestoreUpdateFormFromBody = Annotated[ RestoreCreate, Depends(build_restore_update_form_from_body), ] -RestoreGeneratedTask = Annotated[TaskWrite, Depends(build_restore_task_payload)] - - -RestoresTask = Annotated[Task, Depends(get_restores_task)] - - -def get_restores_task_info(task: dict[str, Any]) -> dict[str, Any]: - """Extract relevant information from a task for the Restores plugin. - - Processes the task data to extract hostname and backup information. - Reads from PBM format config (lowercase keys, camelCase values). - - :param task: The task data retrieved from the Tasks API. - :type task: dict[str, Any] - :return: A dictionary containing hostname and backup information. - :rtype: dict[str, Any] - """ - data = task["data"] - meta = data["meta"] - - task_config = {} - if "config" in meta: - task_config = yaml.safe_load(meta["config"]) or {} - - backup_type = None - if task_config.get("backupType"): - backup_type = BackupType(task_config.get("backupType")).name - - return { - "config": task_config, - "parent": data.get("parent"), - "target": meta["target"], - "hostname": meta["target"], - "backup_type": backup_type, - "created_at": task.get("created_at"), - "created_by": task.get("created_by"), - "last_updated_by": task.get("last_updated_by"), - } - - -async def get_restores_index_context( - inventory_api: InventoryAPI, - tasks_api: TaskAPI, - context: DefaultContext, - executor_hosts_ctx: ExecutorHostsCtx, -) -> dict[str, Any]: - """Assemble the context for the Restores plugin index view. - - Retrieves MongoDB services and associated tasks, organizing them based on their - execution status. Integrates this information into the default context for - rendering in templates. - - :param inventory_api: The Inventory API client for fetching service and schema data. - :type inventory_api: InventoryAPI - :param tasks_api: The TaskAPI client for fetching task data. - :type tasks_api: TaskAPI - :param context: The default context to be updated with Restores-specific information. - :type context: DefaultContext - :param executor_hosts_ctx: The executor hosts context for the Restore tasks. - :type executor_hosts_ctx: ExecutorHostsCtx - :return: An updated context dictionary containing Restores-related data. - :rtype: dict[str, Any] - """ - return await get_tasks_context( - inventory_api, - tasks_api, - get_restores_task_info, - executor_hosts_ctx, - context, - OWNER, - service_type=ServiceTypeEnum.MONGODB, - ) - - -RestoresIndexContext = Annotated[dict[str, Any], Depends(get_restores_index_context)] diff --git a/app/sep/apps/backup_mongo/restore/routes.py b/app/sep/apps/backup_mongo/restore/routes.py deleted file mode 100644 index 514b913603..0000000000 --- a/app/sep/apps/backup_mongo/restore/routes.py +++ /dev/null @@ -1,402 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define legacy Jinja routes for the restores plugin. - -These Jinja2 routes are deprecated. The JSON API equivalents live under -``/api/apps/backup_mongo/restore/`` and the React UI at -``/backups/mongodb/restores`` (``frontend/packages/apps/backup_mongo``). -Every response from this router carries the RFC 8594 ``Deprecation: true`` -header and emits a WARNING on hit; the routes remain mounted for Wave 1 cutover -and will be removed in Wave 3. -""" - -import logging -from typing import Annotated, Any - -import yaml -from fastapi import APIRouter, Form, HTTPException, Request, status -from fastapi.responses import HTMLResponse, RedirectResponse -from pydantic import FutureDatetime - -from app.core.alerts.config import alert_settings -from app.core.pagination import fetch_all_dict_items -from app.inventory.models import ServiceTypeEnum -from app.sep.apps.backup_mongo.models import BackupType -from app.sep.apps.backup_mongo.restore.deps import ( - parse_restore_task_data, - RestoreGeneratedTask, - RestoresIndexContext, - RestoresTask, - RestoreTasks, -) -from app.sep.apps.framework.deprecation import DeprecatedJinja2Route -from app.sep.config import sep_settings -from app.sep.deps import ( - DefaultContext, - ExecutorHostsCtx, - get_chainable_tasks, - InventoryAPI, - IsAuthenticated, - IsCsrfValidated, - TaskAPI, -) -from app.tasks.models import TaskHistoryStatusEnum - -logger = logging.getLogger(__name__) -router = APIRouter(route_class=DeprecatedJinja2Route) -templates = sep_settings.TEMPLATES - - -def _build_task_urls( - task_name: str, task_config: dict[str, Any], request: Request -) -> dict[str, str | None]: - """Build URLs for task action buttons.""" - backup_type = task_config.get("backupType") - sync_config_url = request.url_for("pbm_restores_execute", task_name=task_name) - restore_url = None - if backup_type: - restore_task_name = f"{task_name}-{backup_type}" - restore_url = request.url_for( - "pbm_restores_execute", task_name=restore_task_name - ) - pbm_list_task_name = f"{task_name}-pbm-list" - pbm_list_url = request.url_for("pbm_restores_execute", task_name=pbm_list_task_name) - - force_resync_url = None - if backup_type == BackupType.PBM_PHYSICAL.value: - force_resync_task_name = f"{task_name}-pbm-force-resync" - force_resync_url = request.url_for( - "pbm_restores_execute", task_name=force_resync_task_name - ) - - return { - "sync_config_url": sync_config_url, - "restore_url": restore_url, - "pbm_list_url": pbm_list_url, - "force_resync_url": force_resync_url, - } - - -async def _fetch_running_tasks( - task_name: str, - task_config: dict[str, Any], - tasks_api: TaskAPI, -) -> list[dict[str, Any]]: - """Fetch running tasks for parent and child tasks.""" - backup_type = task_config.get("backupType") - response = await tasks_api.get( - f"/{task_name}/history/", params={"status": TaskHistoryStatusEnum.RUNNING} - ) - running_tasks = response["items"] - - if backup_type: - restore_task_name = f"{task_name}-{backup_type}" - response = await tasks_api.get( - f"/{restore_task_name}/history/", - params={"status": TaskHistoryStatusEnum.RUNNING}, - ) - running_tasks += response["items"] - - pbm_list_task_name = f"{task_name}-pbm-list" - response = await tasks_api.get( - f"/{pbm_list_task_name}/history/", - params={"status": TaskHistoryStatusEnum.RUNNING}, - ) - running_tasks += response["items"] - - if backup_type == BackupType.PBM_PHYSICAL.value: - force_resync_task_name = f"{task_name}-pbm-force-resync" - response = await tasks_api.get( - f"/{force_resync_task_name}/history/", - params={"status": TaskHistoryStatusEnum.RUNNING}, - ) - running_tasks += response["items"] - - return running_tasks - - -async def _fetch_task_history( - task_name: str, - task_config: dict[str, Any], - tasks_api: TaskAPI, -) -> dict[str, list[dict[str, Any]]]: - """Fetch history for child tasks.""" - backup_type = task_config.get("backupType") - pbm_list_task_name = f"{task_name}-pbm-list" - - try: - response = await tasks_api.get(f"/{pbm_list_task_name}/history/") - history_pbm_list = response["items"] - except HTTPException: - history_pbm_list = [] - - if backup_type == BackupType.PBM_PHYSICAL.value: - force_resync_task_name = f"{task_name}-pbm-force-resync" - try: - response = await tasks_api.get(f"/{force_resync_task_name}/history/") - history_force_resync = response["items"] - except HTTPException: - history_force_resync = [] - else: - history_force_resync = [] - - return { - "history_pbm_list": history_pbm_list, - "history_force_resync": history_force_resync, - } - - -@router.get( - "/", - dependencies=[IsAuthenticated], - response_class=HTMLResponse, - name="pbm_restores_index", -) -async def restores_index( - request: Request, - context: RestoresIndexContext, -) -> HTMLResponse: - """Homepage of restores plugin.""" - return templates.TemplateResponse( - request=request, - name="backup_mongo/restore/index.html.j2", - context=context, - ) - - -@router.post( - "/", - dependencies=[IsAuthenticated, IsCsrfValidated], - response_class=HTMLResponse, - name="pbm_restores_create", -) -async def restores_create( - request: Request, - tasks: RestoreTasks, - task_api: TaskAPI, -) -> RedirectResponse: - """Create new restores task.""" - logger.debug("Create restores config task: %s", tasks.config_task) - logger.debug("Create restores task: %s", tasks.restore_task) - logger.debug("Create pbm list task: %s", tasks.pbm_list_task) - if tasks.force_resync_task: - logger.debug("Create pbm force-resync task: %s", tasks.force_resync_task) - - await task_api.post( - "/", - json=tasks.config_task.model_dump(), - ) - - await task_api.post( - "/", - json=tasks.restore_task.model_dump(), - ) - - await task_api.post( - "/", - json=tasks.pbm_list_task.model_dump(), - ) - - if tasks.force_resync_task: - await task_api.post( - "/", - json=tasks.force_resync_task.model_dump(), - ) - - task_path = request.url_for("pbm_restores_detail", task_name=tasks.config_task.name) - return RedirectResponse( - task_path, - status_code=status.HTTP_303_SEE_OTHER, - ) # TODO: Custom redirect class # noqa: TD002, TD003 - - -@router.get( - "/{task_name}", - dependencies=[IsAuthenticated], - response_class=HTMLResponse, - name="pbm_restores_detail", -) -async def restores_detail( - task: RestoresTask, - request: Request, - context: DefaultContext, - inventory_api: InventoryAPI, - tasks_api: TaskAPI, - executor_hosts_ctx: ExecutorHostsCtx, -) -> HTMLResponse: - """Retrieve restores task.""" - data = task.data - - # If the task has a parent, redirect to the parent task detail page - if data.get("parent"): - task_path = request.url_for("pbm_restores_detail", task_name=data.get("parent")) - return RedirectResponse(task_path, status_code=status.HTTP_303_SEE_OTHER) - - meta = data["meta"] - task_config = yaml.safe_load(meta["config"]) - - if "restore" not in task_config: - task_config["restore"] = {} - - meta["config"] = yaml.dump( - task_config, default_flow_style=False, allow_unicode=True - ) - - parsed_task_data = parse_restore_task_data(task.model_dump()) - - urls = _build_task_urls(task.name, task_config, request) - backup_type = task_config.get("backupType") - - task_data = { - "name": task.name, - "created_at": task.created_at, - "updated_at": task.updated_at, - "created_by": task.created_by, - "last_updated_by": task.last_updated_by, - "hostname": meta["target"], - "meta": meta, - "restore_type": BackupType(backup_type).name if backup_type else None, - "backup_source": task_config.get("backupSource"), - "delete_url": request.url_for("pbm_restores_delete", task_name=task.name), - "is_edit_enabled": not task.protected, - "alert_on_fail": task.alert_on_fail, - **urls, - } - - task_data.update(parsed_task_data) - - context["task"] = task_data - response = await tasks_api.get(f"/{task.name}/history/") - all_history = response["items"] - context["history"] = all_history - - try: - response = await tasks_api.get(f"/{task.name}-pbm_logical/history/") - context["history_logical"] = response["items"] - except HTTPException: - context["history_logical"] = [] - - try: - response = await tasks_api.get(f"/{task.name}-pbm_physical/history/") - context["history_physical"] = response["items"] - except HTTPException: - context["history_physical"] = [] - - context["running_tasks"] = await _fetch_running_tasks( - task.name, task_config, tasks_api - ) - - child_history = await _fetch_task_history(task.name, task_config, tasks_api) - context.update(child_history) - - context["stats"] = await tasks_api.get(f"/stats/{task.name}") - - context["executor_hosts"] = executor_hosts_ctx.with_host( - task_data["hostname"] - ).as_template_list() - - try: - context["services"] = await fetch_all_dict_items( - lambda pagination: inventory_api.get( - "/services/", - params={ - "service_type": ServiceTypeEnum.MONGODB, - **pagination.model_dump(), - }, - ) - ) - except HTTPException: - context["services"] = [] - - context["alert_on_fail_default"] = task.alert_on_fail - context["alert_on_fail_available"] = bool(alert_settings.PROVIDERS) - context["chainable_tasks"] = await get_chainable_tasks( - tasks_api, task.owner, meta["target"], task.name - ) - - return templates.TemplateResponse( - request=request, - name="backup_mongo/restore/details.html.j2", - context=context, - ) - - -@router.post( - "/{task_name}", - dependencies=[IsAuthenticated, IsCsrfValidated], - response_class=RedirectResponse, - name="pbm_restores_execute", -) -async def restores_execute( - request: Request, - task: RestoresTask, - tasks_api: TaskAPI, - eta: Annotated[FutureDatetime | None, Form()] = None, - chain_task_names: Annotated[list[str] | None, Form()] = None, - chain_on_failure: Annotated[bool | None, Form()] = None, -) -> RedirectResponse: - """Execute restores task.""" - await tasks_api.post( - f"/execute/{task.name}", - json={ - "eta": eta, - "chain_task_names": chain_task_names, - "chain_on_failure": chain_on_failure, - }, - ) - task_path = request.url_for("pbm_restores_detail", task_name=task.name) - return RedirectResponse(task_path, status_code=status.HTTP_303_SEE_OTHER) - - -@router.post( - "/{task_name}/update", - dependencies=[IsAuthenticated, IsCsrfValidated], - response_class=RedirectResponse, - name="pbm_restores_update", -) -async def restores_update( - request: Request, - task_name: str, - updated_task: RestoreGeneratedTask, - tasks_api: TaskAPI, -) -> RedirectResponse: - """Update restores task.""" - logger.debug("Updating restores task: %s", updated_task) - await tasks_api.put( - f"/{task_name}", - json=updated_task.model_dump(), - ) - return RedirectResponse( - request.url_for("pbm_restores_detail", task_name=updated_task.name), - status_code=status.HTTP_303_SEE_OTHER, - ) - - -@router.post( - "/{task_name}/delete", - dependencies=[IsAuthenticated, IsCsrfValidated], - response_class=RedirectResponse, - name="pbm_restores_delete", -) -async def restores_delete( - request: Request, - task: RestoresTask, - tasks_api: TaskAPI, -) -> RedirectResponse: - """Delete restores task.""" - await tasks_api.delete(f"/{task.name}") - task_path = request.url_for("pbm_restores_index") - return RedirectResponse(task_path, status_code=status.HTTP_303_SEE_OTHER) diff --git a/app/sep/apps/backup_mongo/routes.py b/app/sep/apps/backup_mongo/routes.py deleted file mode 100644 index 0ddf03a63f..0000000000 --- a/app/sep/apps/backup_mongo/routes.py +++ /dev/null @@ -1,235 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define legacy Jinja routes for the backups plugin. - -These Jinja2 routes are deprecated. The JSON API equivalents live under -``/api/apps/backup_mongo/`` and the React UI at ``/backups/mongodb/backups`` -(``frontend/packages/apps/backup_mongo``). Every response from this router -carries the RFC 8594 ``Deprecation: true`` header and emits a WARNING on hit; -the routes remain mounted for Wave 1 cutover and will be removed in Wave 3. -""" - -import logging -from typing import Annotated - -from fastapi import APIRouter, Form, Request, status -from fastapi.responses import HTMLResponse, RedirectResponse -from pydantic import FutureDatetime - -from app.core.alerts.config import alert_settings -from app.core.exceptions import HTTPNotFoundException -from app.sep.apps.backup_mongo.deps import ( - _fetch_latest_pbm_status, - BackupGeneratedTask, - BackupsIndexContextDep, - BackupsTask, - get_backups_task, -) -from app.sep.apps.backup_mongo.schema import BACKUP_MONGO_DERIVED -from app.sep.apps.framework.cascade import cascade_create_tasks -from app.sep.apps.framework.deprecation import DeprecatedJinja2Route -from app.sep.config import sep_settings -from app.sep.deps import ( - DefaultContext, - get_chainable_tasks, - HasNoConflictedRunningTasks, - IsAuthenticated, - IsCsrfValidated, - TaskAPI, -) -from app.tasks.models import TaskHistoryStatusEnum - -logger = logging.getLogger(__name__) -router = APIRouter(route_class=DeprecatedJinja2Route) -templates = sep_settings.TEMPLATES - - -@router.get("/", dependencies=[IsAuthenticated], response_class=HTMLResponse) -async def pbm_backups_index( - request: Request, - context: BackupsIndexContextDep, -) -> HTMLResponse: - """Homepage of PBM backup mongo plugin.""" - return templates.TemplateResponse( - request=request, - name="backup_mongo/backup/index.html.j2", - context=context, - ) - - -@router.post( - "/", dependencies=[IsAuthenticated, IsCsrfValidated], response_class=HTMLResponse -) -async def pbm_backups_create( - request: Request, - task: BackupGeneratedTask, - task_api: TaskAPI, -) -> RedirectResponse: - """Create new backups task.""" - logger.debug("Create backups task: %s", task) - - await cascade_create_tasks(task_api, task.model_dump(), BACKUP_MONGO_DERIVED) - - task_path = request.url_for("pbm_backups_detail", task_name=task.name) - return RedirectResponse( - task_path, - status_code=status.HTTP_303_SEE_OTHER, - ) # TODO: Custom redirect class # noqa: TD002, TD003 - - -@router.get("/{task_name}", dependencies=[IsAuthenticated], response_class=HTMLResponse) -async def pbm_backups_detail( - task: BackupsTask, - request: Request, - context: DefaultContext, - tasks_api: TaskAPI, -) -> HTMLResponse: - """Retrieve backups task.""" - data = task.data - - # If the task has a parent, redirect to the parent task detail page - if data.get("parent"): - task_path = request.url_for("pbm_backups_detail", task_name=data.get("parent")) - return RedirectResponse(task_path, status_code=status.HTTP_303_SEE_OTHER) - - meta = data["meta"] - # Probe for the incremental sibling before linking a Run control. Groups - # created before this release only gain ``-incremental`` on edit/backfill; - # linking a missing task would 404 on execute. - incremental_name = f"{task.name}-incremental" - try: - await get_backups_task(incremental_name, tasks_api) - except HTTPNotFoundException: - has_incremental = False - else: - has_incremental = True - - task_data = { - "name": task.name, - "created_at": task.created_at, - "updated_at": task.updated_at, - "created_by": task.created_by, - "last_updated_by": task.last_updated_by, - "hostname": meta["target"], - "meta": meta, - "backup_type": data["backup_type"], - "logical_backup_url": request.url_for( - "pbm_backups_execute", task_name=task.name + "-logical" - ), - "physical_backup_url": request.url_for( - "pbm_backups_execute", task_name=task.name + "-physical" - ), - "alert_on_fail": task.alert_on_fail, - } - if has_incremental: - task_data["incremental_backup_url"] = request.url_for( - "pbm_backups_execute", task_name=incremental_name - ) - - context["task"] = task_data - response = await tasks_api.get(f"/{task.name}/history/") - context["history"] = response["items"] - response = await tasks_api.get(f"/{task.name}-logical/history/") - context["history_logical"] = response["items"] - response = await tasks_api.get(f"/{task.name}-physical/history/") - context["history_physical"] = response["items"] - if has_incremental: - response = await tasks_api.get(f"/{incremental_name}/history/") - context["history_incremental"] = response["items"] - else: - context["history_incremental"] = [] - response = await tasks_api.get( - f"/{task.name}/history/", params={"status": TaskHistoryStatusEnum.RUNNING} - ) - context["running_tasks"] = response["items"] - response = await tasks_api.get( - f"/{task.name}-logical/history/", - params={"status": TaskHistoryStatusEnum.RUNNING}, - ) - context["running_tasks"] += response["items"] - response = await tasks_api.get( - f"/{task.name}-physical/history/", - params={"status": TaskHistoryStatusEnum.RUNNING}, - ) - context["running_tasks"] += response["items"] - if has_incremental: - response = await tasks_api.get( - f"/{incremental_name}/history/", - params={"status": TaskHistoryStatusEnum.RUNNING}, - ) - context["running_tasks"] += response["items"] - context["stats"] = await tasks_api.get(f"/stats/{task.name}") - - response = await tasks_api.get(f"/{task.name}-status/history/") - pbm_status_tasks = response["items"] - context["latest_status"] = await _fetch_latest_pbm_status( - tasks_api, pbm_status_tasks - ) - - context["alert_on_fail_default"] = task.alert_on_fail - context["alert_on_fail_available"] = bool(alert_settings.PROVIDERS) - context["chainable_tasks"] = await get_chainable_tasks( - tasks_api, task.owner, meta["target"], task.name - ) - - return templates.TemplateResponse( - request=request, - name="backup_mongo/backup/details.html.j2", - context=context, - ) - - -@router.post( - "/{task_name}", - dependencies=[IsAuthenticated, IsCsrfValidated, HasNoConflictedRunningTasks], - response_class=RedirectResponse, -) -async def pbm_backups_execute( - request: Request, - task: BackupsTask, - tasks_api: TaskAPI, - eta: Annotated[FutureDatetime | None, Form()] = None, - chain_task_names: Annotated[list[str] | None, Form()] = None, - chain_on_failure: Annotated[bool | None, Form()] = None, -) -> RedirectResponse: - """Execute backups task.""" - await tasks_api.post( - f"/execute/{task.name}", - json={ - "eta": eta, - "chain_task_names": chain_task_names, - "chain_on_failure": chain_on_failure, - }, - ) - task_path = request.url_for("pbm_backups_detail", task_name=task.name) - return RedirectResponse(task_path, status_code=status.HTTP_303_SEE_OTHER) - - -@router.post( - "/{task_name}/delete", - dependencies=[IsAuthenticated, IsCsrfValidated], - response_class=RedirectResponse, -) -async def pbm_backups_delete( - request: Request, - task: BackupsTask, - tasks_api: TaskAPI, -) -> RedirectResponse: - """Delete backups task.""" - await tasks_api.delete(f"/{task.name}") - await tasks_api.delete(f"/{task.name}-logical") - task_path = request.url_for("pbm_backups_index") - return RedirectResponse(task_path, status_code=status.HTTP_303_SEE_OTHER) diff --git a/app/sep/apps/backup_pg/app.py b/app/sep/apps/backup_pg/app.py index 3a879876df..60f793330c 100644 --- a/app/sep/apps/backup_pg/app.py +++ b/app/sep/apps/backup_pg/app.py @@ -30,8 +30,7 @@ the list, detail, create, and update bodies stay byte-identical. The derived PUT and DELETE carry the framework's default protected-task + running-conflict guards; only the body-reading create running-conflict guard rides explicitly via -``create_extra_deps``. The Jinja UI router is threaded explicitly as -``jinja_router``; the registry does not. +``create_extra_deps``. """ from app.inventory.models import ServiceTypeEnum @@ -46,7 +45,6 @@ BackupTaskResponse, OWNER, ) -from app.sep.apps.backup_pg.routes import router as jinja_router from app.sep.apps.backup_pg.spec import build_backup_pg_spec from app.sep.apps.backup_pg.views import backup_pg_views from app.sep.apps.framework.apps import ( @@ -81,5 +79,4 @@ detail_response_builder=build_backup_pg_api_detail_response, detail_response_model=BackupTaskDetailResponse, create_extra_deps=(HasNoConflictedRunningTasksOnCreate,), - jinja_router=jinja_router, ) diff --git a/app/sep/apps/backup_pg/deps.py b/app/sep/apps/backup_pg/deps.py index 37d4e7b261..eee7e20858 100644 --- a/app/sep/apps/backup_pg/deps.py +++ b/app/sep/apps/backup_pg/deps.py @@ -17,76 +17,24 @@ import logging from datetime import datetime -from typing import Annotated, Any +from typing import Any import yaml -from fastapi import Depends, Form, Request +from fastapi import Depends, Request from app.core.exceptions import HTTPConflictException from app.inventory.constants import DEFAULT_POSTGRESQL_PORT from app.inventory.models import ServiceTypeEnum -from app.sep.apps.backup_pg.models import ( - BackupPgForm, - BackupTaskDetailResponse, - BackupTaskResponse, - BackupType, - OWNER, -) -from app.sep.apps.backup_pg.spec import build_backup_pg_spec -from app.sep.apps.framework import build_default_task_response, make_task_dep -from app.sep.apps.framework.spec import ( - assemble_envelope, - resolve_refs, -) +from app.sep.apps.backup_pg.models import BackupTaskDetailResponse, BackupTaskResponse +from app.sep.apps.framework import build_default_task_response from app.sep.apps.shared.backups.edit_form import parse_server_list_config from app.sep.connectivity import CONNECTIVITY_META_PORT_KEY -from app.sep.deps import ( - DefaultContext, - ExecutorHostsCtx, - get_tasks_context, - InventoryAPI, - TaskAPI, -) -from app.tasks.models import Task, TaskHistoryStatusEnum, TaskWrite +from app.sep.deps import TaskAPI +from app.tasks.models import Task, TaskHistoryStatusEnum logger = logging.getLogger(__name__) -async def build_backup_task_payload( - form: Annotated[BackupPgForm, Form()], - inventory_api: InventoryAPI, -) -> TaskWrite: - """Build the backup task payload from a form-urlencoded body. - - The legacy Jinja form path's payload dependency. Resolves the form's - reference fields and feeds the shared pure - :func:`~app.sep.apps.backup_pg.spec.build_backup_pg_spec` through the - framework's ``assemble_envelope`` — the same pair the model-first JSON create - route uses — so a form-created task's Nomad payload stays byte-identical to a - JSON-created one. - - :param form: The form data for the Backups creation. - :param inventory_api: The Inventory API to resolve the service reference. - :return: A fully constructed ``TaskWrite`` object. - """ - resolved = await resolve_refs(form, inventory_api) - return assemble_envelope( - build_backup_pg_spec(form, resolved), - resolved, - name=form.task_name, - owner=OWNER, - alert_on_fail=form.alert_on_fail, - ) - - -BackupGeneratedTask = Annotated[TaskWrite, Depends(build_backup_task_payload)] - - -get_backups_task = make_task_dep(OWNER) - -BackupsTask = Annotated[Task, Depends(get_backups_task)] - - async def check_create_has_no_conflicted_running_tasks( request: Request, tasks_api: TaskAPI, @@ -226,69 +174,6 @@ def build_backup_pg_api_detail_response( ) -def get_backups_task_info(task: dict[str, Any]) -> dict[str, Any]: - """Extract relevant information from a task for the Backups plugin. - - Processes the task data to extract hostname and tables information. - - :param task: The task data retrieved from the Tasks API. - :type task: dict[str, Any] - :return: A dictionary containing hostname and tables information. - :rtype: dict[str, Any] - """ - data = task["data"] - meta = data["meta"] - task_config = yaml.safe_load(meta["config"]) - backup_server = task_config["SERVER_LIST"][0] - - return { - "hostname": meta["target"], - "host": backup_server.get("HOST"), - "port": backup_server.get("PORT") - or meta.get(CONNECTIVITY_META_PORT_KEY) - or DEFAULT_POSTGRESQL_PORT, - "backup_type": BackupType(backup_server.get("BACKUP_TYPE")).name, - } - - -async def get_backups_index_context( - inventory_api: InventoryAPI, - tasks_api: TaskAPI, - context: DefaultContext, - executor_hosts_ctx: ExecutorHostsCtx, -) -> dict[str, Any]: - """Assemble the context for the Backups plugin index view. - - Retrieves PostgreSQL services and associated tasks, organizing them based on their - execution status. Integrates this information into the default context for - rendering in templates. - - :param inventory_api: The Inventory API client for fetching service and schema data. - :type inventory_api: InventoryAPI - :param tasks_api: The TaskAPI client for fetching task data. - :type tasks_api: TaskAPI - :param context: The default context to be updated with Backups-specific information. - :type context: DefaultContext - :param executor_hosts_ctx: The executor hosts context for the Backups tasks. - :type executor_hosts_ctx: ExecutorHostsCtx - :return: An updated context dictionary containing Backups-related data. - :rtype: dict[str, Any] - """ - return await get_tasks_context( - inventory_api, - tasks_api, - get_backups_task_info, - executor_hosts_ctx, - context, - OWNER, - service_type=ServiceTypeEnum.POSTGRESQL, - alert_on_fail_default=True, - ) - - -BackupsIndexContext = Annotated[dict[str, Any], Depends(get_backups_index_context)] - - def parse_backup_task_data(task: dict[str, Any]) -> dict[str, Any]: """Parse backup task data for editing. diff --git a/app/sep/apps/backup_pg/routes.py b/app/sep/apps/backup_pg/routes.py deleted file mode 100644 index 0d0b4de920..0000000000 --- a/app/sep/apps/backup_pg/routes.py +++ /dev/null @@ -1,271 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define legacy Jinja routes for the PostgreSQL backups app. - -These Jinja2 routes are deprecated. The JSON API equivalents live under -``/api/apps/backup_pg/`` and the React UI renders the app through the generic -schema-driven shell in ``frontend/packages/framework``; the app ships no -bespoke React package of its own. Every response from this router carries the -RFC 8594 ``Deprecation: true`` header and emits a WARNING on hit; the routes -remain mounted only until the Jinja layer is removed. -""" - -import logging -from typing import Annotated - -import yaml -from fastapi import APIRouter, Form, HTTPException, Request, status -from fastapi.responses import HTMLResponse, RedirectResponse -from pydantic import FutureDatetime - -from app.core.alerts.config import alert_settings -from app.core.pagination import fetch_all_dict_items -from app.inventory.models import ServiceTypeEnum -from app.sep.apps.backup_pg.deps import ( - BackupGeneratedTask, - BackupsIndexContext, - BackupsTask, - parse_backup_task_data, -) -from app.sep.apps.backup_pg.models import BackupType -from app.sep.apps.framework.deprecation import DeprecatedJinja2Route -from app.sep.config import sep_settings -from app.sep.connectivity import maybe_check_connectivity -from app.sep.deps import ( - CheckConnectivityFlag, - DefaultContext, - ExecutorHostsCtx, - get_chainable_tasks, - HasNoConflictedRunningTasks, - InventoryAPI, - IsAuthenticated, - IsCsrfValidated, - TaskAPI, -) -from app.tasks.models import TaskHistoryStatusEnum - -logger = logging.getLogger(__name__) -router = APIRouter(route_class=DeprecatedJinja2Route) -templates = sep_settings.TEMPLATES - - -@router.get( - "/", - dependencies=[IsAuthenticated], - response_class=HTMLResponse, - include_in_schema=False, -) -async def pg_backups_index( - request: Request, - context: BackupsIndexContext, -) -> HTMLResponse: - """Render the PG backups index page. - - :param request: The incoming HTTP request. - :param context: Template context assembled by ``BackupsIndexContext``. - :return: The rendered index page. - """ - return templates.TemplateResponse( - request=request, - name="backup_pg/index.html.j2", - context=context, - ) - - -@router.post( - "/", - dependencies=[IsAuthenticated, IsCsrfValidated], - response_class=HTMLResponse, - include_in_schema=False, -) -async def pg_backups_create( - request: Request, - task: BackupGeneratedTask, - task_api: TaskAPI, - *, - check_connectivity: CheckConnectivityFlag, -) -> RedirectResponse: - """Create new backups task. - - :param request: The incoming HTTP request. - :param task: The task payload built from the submitted form. - :param task_api: The tasks-API client used to create the task. - :param check_connectivity: Whether to probe the target after creation. - :return: HTTP 303 redirect to the new task's detail page. - """ - logger.debug("Create backups task: %s", task) - await task_api.post( - "/", - json=task.model_dump(), - ) - await maybe_check_connectivity( - request, - task_api, - task.data.get("meta", {}), - check_connectivity=check_connectivity, - ) - task_path = request.url_for("pg_backups_detail", task_name=task.name) - return RedirectResponse( - task_path, - status_code=status.HTTP_303_SEE_OTHER, - ) # TODO: Custom redirect class # noqa: TD002, TD003 - - -@router.get( - "/{task_name}", - dependencies=[IsAuthenticated], - response_class=HTMLResponse, - include_in_schema=False, -) -async def pg_backups_detail( - task: BackupsTask, - request: Request, - context: DefaultContext, - inventory_api: InventoryAPI, - tasks_api: TaskAPI, - executor_hosts_ctx: ExecutorHostsCtx, -) -> HTMLResponse: - """Retrieve backups task. - - :param task: The task resolved from the ``task_name`` path param. - :param request: The incoming HTTP request. - :param context: Base template context. - :param inventory_api: The inventory-API client used to list services. - :param tasks_api: The tasks-API client used to fetch history and stats. - :param executor_hosts_ctx: Executor hosts for the host selector. - :return: The rendered task detail page. - """ - data = task.data - meta = data["meta"] - decoded_entities = task.anonymized_entities - task_config = yaml.safe_load(meta["config"]) - server_config = task_config["SERVER_LIST"][0] - - parsed_task_data = parse_backup_task_data(task.model_dump()) - - task_data = { - "name": task.name, - "created_at": task.created_at, - "updated_at": task.updated_at, - "hostname": meta["target"], - "meta": meta, - "host": server_config["HOST"], - "port": server_config.get("PORT") or 3306, - "backup_type": BackupType(server_config["BACKUP_TYPE"]).name, - "entities": {entity.name: entity.value for entity in decoded_entities}, - "delete_url": request.url_for("pg_backups_delete", task_name=task.name), - "config": task_config.get("ALL_SERVERS", {}), - "is_edit_enabled": not task.protected, - "is_edit_form_present": False, - "alert_on_fail": task.alert_on_fail, - } - - task_data.update(parsed_task_data) - - context["task"] = task_data - response = await tasks_api.get(f"/{task.name}/history/") - context["history"] = response["items"] - response = await tasks_api.get( - f"/{task.name}/history/", params={"status": TaskHistoryStatusEnum.RUNNING} - ) - context["running_tasks"] = response["items"] - context["stats"] = await tasks_api.get(f"/stats/{task.name}") - - context["executor_hosts"] = executor_hosts_ctx.with_host( - task_data["hostname"] - ).as_template_list() - - try: - context["services"] = await fetch_all_dict_items( - lambda pagination: inventory_api.get( - "/services/", - params={ - "service_type": ServiceTypeEnum.POSTGRESQL, - **pagination.model_dump(), - }, - ) - ) - except HTTPException: - context["services"] = [] - - context["alert_on_fail_default"] = task.alert_on_fail - context["alert_on_fail_available"] = bool(alert_settings.PROVIDERS) - context["chainable_tasks"] = await get_chainable_tasks( - tasks_api, task.owner, meta["target"], task.name - ) - - return templates.TemplateResponse( - request=request, - name="backup_pg/details.html.j2", - context=context, - ) - - -@router.post( - "/{task_name}", - dependencies=[IsAuthenticated, IsCsrfValidated, HasNoConflictedRunningTasks], - response_class=RedirectResponse, - include_in_schema=False, -) -async def pg_backups_execute( - request: Request, - task: BackupsTask, - tasks_api: TaskAPI, - eta: Annotated[FutureDatetime | None, Form()] = None, - chain_task_names: Annotated[list[str] | None, Form()] = None, - chain_on_failure: Annotated[bool | None, Form()] = None, -) -> RedirectResponse: - """Execute backups task. - - :param request: The incoming HTTP request. - :param task: The task resolved from the ``task_name`` path param. - :param tasks_api: The tasks-API client used to dispatch the run. - :param eta: Optional future time to schedule the run for. - :param chain_task_names: Optional task names to chain after this run. - :param chain_on_failure: Whether the chain continues when this run fails. - :return: HTTP 303 redirect to the task's detail page. - """ - await tasks_api.post( - f"/execute/{task.name}", - json={ - "eta": eta, - "chain_task_names": chain_task_names, - "chain_on_failure": chain_on_failure, - }, - ) - task_path = request.url_for("pg_backups_detail", task_name=task.name) - return RedirectResponse(task_path, status_code=status.HTTP_303_SEE_OTHER) - - -@router.post( - "/{task_name}/delete", - dependencies=[IsAuthenticated, IsCsrfValidated], - response_class=RedirectResponse, - include_in_schema=False, -) -async def pg_backups_delete( - task: BackupsTask, - tasks_api: TaskAPI, -) -> RedirectResponse: - """Delete backups task. - - :param task: The PG backups task to delete, resolved by the - ``BackupsTask`` dependency from the ``task_name`` path param. - :param tasks_api: The tasks-API client used to issue the delete call. - :return: HTTP 303 redirect to the app index. - """ - await tasks_api.delete(f"/{task.name}") - return RedirectResponse("/backup_pg", status_code=status.HTTP_303_SEE_OTHER) diff --git a/app/sep/apps/checksums/app.py b/app/sep/apps/checksums/app.py index 3af8d257e7..ca1492dc96 100644 --- a/app/sep/apps/checksums/app.py +++ b/app/sep/apps/checksums/app.py @@ -21,14 +21,12 @@ delete surfaces. Create and update are derived from the model-first :class:`~app.sep.apps.checksums.models.ChecksumsForm` through the ``pt-table-checksum`` spec builder; the derived PUT and DELETE carry the -framework's default protected-task + running-conflict guards. The Jinja UI router -is threaded explicitly (the registry does not). +framework's default protected-task + running-conflict guards. """ from app.inventory.models import ServiceTypeEnum from app.sep.apps.checksums.deps import build_checksums_payload from app.sep.apps.checksums.models import ChecksumsForm, OWNER -from app.sep.apps.checksums.routes import router as jinja_router from app.sep.apps.checksums.views import checksums_views from app.sep.apps.framework.apps import ( AppCapabilities, @@ -54,5 +52,4 @@ service_type=ServiceTypeEnum.MYSQL, list_filter=ListFilterConfig(status=True, service_type=True), response_context_provider=get_username_mapping, - jinja_router=jinja_router, ) diff --git a/app/sep/apps/checksums/deps.py b/app/sep/apps/checksums/deps.py index 959e6b8d1d..75c6df2d5f 100644 --- a/app/sep/apps/checksums/deps.py +++ b/app/sep/apps/checksums/deps.py @@ -16,27 +16,12 @@ """Define dependencies for the Checksums plugin.""" import logging -import shlex -from collections.abc import Iterable -from typing import Annotated, Any -from fastapi import Depends, Form - -from app.inventory.constants import DEFAULT_MYSQL_PORT -from app.inventory.models import ServiceTypeEnum -from app.sep.apps.checksums.models import ( - ChecksumsCreate, - ChecksumsForm, - coerce_target_list, - OWNER, -) +from app.sep.apps.checksums.models import ChecksumsForm, OWNER from app.sep.apps.checksums.spec import ( - build_checksums_arg_prefix, - build_checksums_command_args, build_checksums_spec, resolve_checksums_target_args, ) -from app.sep.apps.framework import make_task_dep from app.sep.apps.framework.form_dsl import ( derive_arg_parser_from_model, make_arg_parser, @@ -44,141 +29,14 @@ from app.sep.apps.framework.spec import ( assemble_envelope, resolve_refs, - service_id_meta, stamp_form_input, ) -from app.sep.connectivity import ( - CONNECTIVITY_META_HOST_KEY, - CONNECTIVITY_META_PORT_KEY, - CONNECTIVITY_META_SERVICE_TYPE_KEY, -) -from app.sep.deps import ( - DefaultContext, - ExecutorHostsCtx, - get_created_entity, - get_tasks_context, - InventoryAPI, - TaskAPI, -) -from app.sep.inventory import CreatedService -from app.sep.models import SyncInventoryEntityTypeEnum -from app.tasks.models import ( - Task, - TaskBackendEnum, - TaskHistoryStatusEnum, - TaskWrite, -) +from app.sep.deps import InventoryAPI +from app.tasks.models import TaskWrite logger = logging.getLogger(__name__) -def legacy_checksums_create_to_form( - flat: ChecksumsCreate, -) -> tuple[ChecksumsForm, list[str]]: - """Map legacy Jinja form POST data to a validated :class:`ChecksumsForm`. - - Merges comma-separated ``databases`` / ``tables`` fields, legacy inventory - ``schema_id`` / ``table_id`` selections, and ``--databases`` / ``--tables`` - tokens from ``extra_args`` into the model-first target lists. Returns the - validated form and any remaining CLI tokens for the legacy path's - ``extra_remaining_args`` thread-through. - - :param flat: The legacy HTML form body. - :return: ``(form, remaining_args)`` where ``remaining_args`` excludes target - ``--databases`` / ``--tables`` tokens. - """ - databases = list(coerce_target_list(flat.databases)) - tables = list(coerce_target_list(flat.tables)) - - if flat.schema_id and -1 not in flat.schema_id: - databases.extend(schema_id for schema_id in flat.schema_id if schema_id > 0) - - if flat.table_id: - tables.extend(tid for tid in flat.table_id if tid > 0) - - remaining_args = [] - if flat.extra_args: - for arg in shlex.split(flat.extra_args): - if arg.startswith("--databases="): - databases.extend(coerce_target_list(arg.split("=", 1)[1])) - elif arg.startswith("--tables="): - tables.extend(coerce_target_list(arg.split("=", 1)[1])) - else: - remaining_args.append(arg) - - form = ChecksumsForm.model_validate( - { - **flat.model_dump( - exclude={"schema_id", "table_id", "extra_args", "databases", "tables"} - ), - "databases": databases, - "tables": tables, - } - ) - return form, remaining_args - - -def assemble_checksum_payload( - service: CreatedService, - form: ChecksumsForm, - *, - databases_arg: str, - tables_arg: str, - extra_remaining_args: Iterable[str] = (), -) -> TaskWrite: - """Assemble a TaskWrite for pt-table-checksum from a validated form. - - The legacy Jinja form path's envelope builder. Builds the CLI argument string - from the shared - :func:`~app.sep.apps.checksums.spec.build_checksums_arg_prefix` plus - :func:`~app.sep.apps.checksums.spec.build_checksums_command_args` — the same - pair the model-first JSON path uses — and assembles the ``TaskWrite`` meta, so - a form-created task's Nomad payload stays byte-identical to a JSON-created one. - - :param service: The validated inventory service instance. - :param form: The validated checksums create form. - :param databases_arg: Comma-separated database names (pre-resolved). - :param tables_arg: Comma-separated ``schema.table`` strings (pre-resolved). - :param extra_remaining_args: Additional pre-parsed CLI args (form path only). - :return: A fully constructed ``TaskWrite`` object. - """ - args = build_checksums_arg_prefix( - service, - recursion_method=form.recursion_method, - dsn_table=form.dsn_table, - extra_remaining_args=extra_remaining_args, - ) + build_checksums_command_args( - form, - databases_arg=databases_arg, - tables_arg=tables_arg, - ) - - return TaskWrite( - owner=OWNER, - backend=TaskBackendEnum.PROXY, - data={ - "task": "run-command", - "meta": { - "command": "pt-table-checksum", - "args": shlex.join(args), - "target": form.hostname, - "_service_name": service.name, - "_service_host": service.node.address, - "_service_port": service.port, - CONNECTIVITY_META_HOST_KEY: service.node.address, - CONNECTIVITY_META_PORT_KEY: service.port or DEFAULT_MYSQL_PORT, - CONNECTIVITY_META_SERVICE_TYPE_KEY: service.type.value, - # This builder never reaches assemble_envelope, so it merges the - # framework's fragment directly. - **service_id_meta(service), - }, - }, - name=form.task_name, - target=form.hostname, - alert_on_fail=form.alert_on_fail, - ) - - async def build_checksums_payload( form: ChecksumsForm, inventory_api: InventoryAPI, @@ -211,96 +69,6 @@ async def build_checksums_payload( return write -async def build_checksums_task_payload( - form: Annotated[ChecksumsCreate, Form()], - inventory_api: InventoryAPI, -) -> TaskWrite: - """Build the checksums task payload from form. - - Build the payload for an Checksums task to be executed, including the - necessary command arguments. - - :param form: The form data for the Checksums creation. - :type form: ChecksumsCreate - :param inventory_api: The Inventory API to get entities from. - :type inventory_api: InventoryAPI - :return: A fully constructed ``TaskWrite`` object containing all the necessary - commands and parameters for the Checksums task execution. - :rtype: TaskWrite - """ - service = await get_created_entity( - inventory_api, - SyncInventoryEntityTypeEnum.SERVICE, - form.service_id, - type=ServiceTypeEnum.MYSQL, - ) - checksums_form, remaining_args = legacy_checksums_create_to_form(form) - databases_arg, tables_arg = await resolve_checksums_target_args( - checksums_form, inventory_api - ) - - return assemble_checksum_payload( - service, - checksums_form, - databases_arg=databases_arg, - tables_arg=tables_arg, - extra_remaining_args=remaining_args, - ) - - -ChecksumsGeneratedTask = Annotated[TaskWrite, Depends(build_checksums_task_payload)] - - -get_checksums_task = make_task_dep(OWNER) - -ChecksumsTask = Annotated[Task, Depends(get_checksums_task)] - - -async def get_checksums_task_names_by_status( - tasks_api: TaskAPI, - status: TaskHistoryStatusEnum, -) -> set[str]: - """Retrieve checksum task names that have histories with the requested status. - - :param tasks_api: The TaskAPI instance used to query task histories. - :type tasks_api: TaskAPI - :param status: The status used to filter checksum task histories. - :type status: TaskHistoryStatusEnum - :return: The set of checksum task names that have at least one matching history. - :rtype: set[str] - """ - response = await tasks_api.get("/history/", params={"status": status}) - histories = response["items"] - return { - history["task"]["name"] - for history in histories - if history.get("task", {}).get("owner") == OWNER - } - - -def get_checksums_task_info(task: dict[str, Any]) -> dict[str, Any]: - """Extract relevant information from a task for the Checksums plugin. - - Processes the task data to extract hostname and service name information. - - :param task: The task data retrieved from the Tasks API. - :type task: dict[str, Any] - :return: A dictionary containing hostname and table information. - :rtype: dict[str, Any] - """ - data = task["data"] - meta = data["meta"] - service_name = "" - if "_service_name" in meta: - service_name = meta["_service_name"] - return { - "hostname": meta["target"], - "service_name": f"{service_name}", - "created_by": task.get("created_by"), - "last_updated_by": task.get("last_updated_by"), - } - - _CHECKSUMS_DEFAULTS = { "recursion_method": "processlist", "databases": "", @@ -333,56 +101,3 @@ def get_checksums_task_info(task: dict[str, Any]) -> dict[str, Any]: flag_mappings=_CHECKSUMS_FLAG_MAPPINGS, skip_leading_positional=True, ) - - -def extract_service_info(meta: dict[str, Any]) -> dict[str, Any]: - """Extract service information from task meta. - - :param meta: The task meta data. - :type meta: dict[str, Any] - :return: A dictionary containing service information. - :rtype: dict[str, Any] - """ - return { - "service_host": meta.get("_service_host", ""), - "service_port": meta.get("_service_port", ""), - "service_name": meta.get("_service_name", ""), - } - - -async def get_checksums_index_context( - inventory_api: InventoryAPI, - tasks_api: TaskAPI, - context: DefaultContext, - executor_hosts_ctx: ExecutorHostsCtx, -) -> dict[str, Any]: - """Assemble the context for the Checksums plugin index view. - - Retrieves MySQL services and associated tasks, organizing them based on their - execution status. Integrates this information into the default context for - rendering in templates. - - :param inventory_api: The Inventory API client for fetching service and schema data. - :type inventory_api: InventoryAPI - :param tasks_api: The TaskAPI client for fetching task data. - :type tasks_api: TaskAPI - :param context: The default context to be updated with Checksums-specific information. - :type context: DefaultContext - :param executor_hosts_ctx: The executor hosts context for the Checksums tasks. - :type executor_hosts_ctx: ExecutorHostsCtx - :return: An updated context dictionary containing Checksums-related data. - :rtype: dict[str, Any] - """ - return await get_tasks_context( - inventory_api, - tasks_api, - get_checksums_task_info, - executor_hosts_ctx, - context, - OWNER, - service_type=ServiceTypeEnum.MYSQL, - alert_on_fail_default=True, - ) - - -ChecksumsIndexContext = Annotated[dict[str, Any], Depends(get_checksums_index_context)] diff --git a/app/sep/apps/checksums/routes.py b/app/sep/apps/checksums/routes.py deleted file mode 100644 index 4b5efa3b78..0000000000 --- a/app/sep/apps/checksums/routes.py +++ /dev/null @@ -1,230 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define routes for the checksums plugin.""" - -import logging -from typing import Annotated - -from fastapi import APIRouter, Form, HTTPException, Request, status -from fastapi.responses import HTMLResponse, RedirectResponse -from pydantic import FutureDatetime - -from app.core.alerts.config import alert_settings -from app.core.pagination import fetch_all_dict_items -from app.inventory.models import ServiceTypeEnum -from app.sep.apps.checksums.deps import ( - ChecksumsGeneratedTask, - ChecksumsIndexContext, - ChecksumsTask, - extract_service_info, - parse_checksums_task_args, -) -from app.sep.apps.framework.deprecation import DeprecatedJinja2Route -from app.sep.config import sep_settings -from app.sep.connectivity import maybe_check_connectivity -from app.sep.deps import ( - CheckConnectivityFlag, - DefaultContext, - ExecutorHostsCtx, - get_chainable_tasks, - HasNoConflictedRunningTasks, - InventoryAPI, - IsAuthenticated, - IsCsrfValidated, - TaskAPI, -) -from app.tasks.models import TaskHistoryStatusEnum - -logger = logging.getLogger(__name__) -router = APIRouter(route_class=DeprecatedJinja2Route) -templates = sep_settings.TEMPLATES - - -@router.get("/", dependencies=[IsAuthenticated], response_class=HTMLResponse) -async def checksums_index( - request: Request, - context: ChecksumsIndexContext, -) -> HTMLResponse: - """Homepage of checksums plugin.""" - context["csrf_token"] = request.state.csrf_token - return templates.TemplateResponse( - request=request, - name="checksums/index.html.j2", - context=context, - ) - - -@router.post( - "/", dependencies=[IsAuthenticated, IsCsrfValidated], response_class=HTMLResponse -) -async def checksums_create( - request: Request, - task: ChecksumsGeneratedTask, - task_api: TaskAPI, - *, - check_connectivity: CheckConnectivityFlag, -) -> RedirectResponse: - """Create an checksum task.""" - logger.debug("Create checksums task: %s", task) - await task_api.post( - "/", - json=task.model_dump(), - ) - await maybe_check_connectivity( - request, - task_api, - task.data.get("meta", {}), - check_connectivity=check_connectivity, - ) - - task_path = request.url_for("checksums_detail", task_name=task.name) - return RedirectResponse( - task_path, - status_code=status.HTTP_303_SEE_OTHER, - ) # TODO: Custom redirect class # noqa: TD002, TD003 - - -@router.get("/{task_name}", dependencies=[IsAuthenticated], response_class=HTMLResponse) -async def checksums_detail( - task: ChecksumsTask, - request: Request, - context: DefaultContext, - tasks_api: TaskAPI, - inventory_api: InventoryAPI, - executor_hosts_ctx: ExecutorHostsCtx, -) -> HTMLResponse: - """Retrieve checksums task.""" - data = task.data - meta = data["meta"] - decoded_entities = task.anonymized_entities - task_data = { - "name": task.name, - "created_at": task.created_at, - "updated_at": task.updated_at, - "created_by": task.created_by, - "last_updated_by": task.last_updated_by, - "hostname": meta["target"], - "cmd": f"{meta['command']} {meta['args']}", - "meta": meta, - "entities": {entity.name: entity.value for entity in decoded_entities}, - "delete_url": request.url_for("checksums_delete", task_name=task.name), - "alert_on_fail": task.alert_on_fail, - "is_edit_enabled": not task.protected, - } - task_data.update(extract_service_info(meta)) - task_data.update(parse_checksums_task_args(meta)) - - context["task"] = task_data - - try: - services = await fetch_all_dict_items( - lambda pagination: inventory_api.get( - "/services/", - params={ - "service_type": ServiceTypeEnum.MYSQL, - **pagination.model_dump(), - }, - ) - ) - except HTTPException as exc: - services = [] - logger.warning("Failed to get services: %s", exc) - - context["executor_hosts"] = executor_hosts_ctx.with_host( - task_data["hostname"] - ).as_template_list() - context["services"] = services - - # TODO(yan): Refactor/reuse like with get_tasks_context # noqa: TD003 - response = await tasks_api.get(f"/{task.name}/history/") - context["history"] = response["items"] - response = await tasks_api.get( - f"/{task.name}/history/", params={"status": TaskHistoryStatusEnum.RUNNING} - ) - context["running_tasks"] = response["items"] - context["stats"] = await tasks_api.get(f"/stats/{task.name}") - context["alert_on_fail_default"] = task_data["alert_on_fail"] - context["alert_on_fail_available"] = bool(alert_settings.PROVIDERS) - context["chainable_tasks"] = await get_chainable_tasks( - tasks_api, task.owner, meta["target"], task.name - ) - return templates.TemplateResponse( - request=request, - name="checksums/details.html.j2", - context=context, - ) - - -@router.post( - "/{task_name}", - dependencies=[IsAuthenticated, IsCsrfValidated, HasNoConflictedRunningTasks], - response_class=RedirectResponse, -) -async def checksums_execute( - task: ChecksumsTask, - tasks_api: TaskAPI, - eta: Annotated[FutureDatetime | None, Form()] = None, - chain_task_names: Annotated[list[str] | None, Form()] = None, - chain_on_failure: Annotated[bool | None, Form()] = None, -) -> RedirectResponse: - """Execute checksums task.""" - await tasks_api.post( - f"/execute/{task.name}", - json={ - "eta": eta, - "chain_task_names": chain_task_names, - "chain_on_failure": chain_on_failure, - }, - ) - return RedirectResponse("/checksums", status_code=status.HTTP_303_SEE_OTHER) - - -@router.post( - "/{task_name}/update", - dependencies=[IsAuthenticated, IsCsrfValidated], - response_class=RedirectResponse, -) -async def checksums_update( - request: Request, - task_name: str, - updated_task: ChecksumsGeneratedTask, - tasks_api: TaskAPI, -) -> RedirectResponse: - """Update checksums task.""" - logger.debug("Updating checksums task: %s", updated_task) - await tasks_api.put( - f"/{task_name}", - json=updated_task.model_dump(), - ) - - return RedirectResponse( - request.url_for("checksums_detail", task_name=updated_task.name), - status_code=status.HTTP_303_SEE_OTHER, - ) - - -@router.post( - "/{task_name}/delete", - dependencies=[IsAuthenticated, IsCsrfValidated], - response_class=RedirectResponse, -) -async def checksums_delete( - task: ChecksumsTask, - tasks_api: TaskAPI, -) -> RedirectResponse: - """Delete checksums task.""" - await tasks_api.delete(f"/{task.name}") - return RedirectResponse("/checksums", status_code=status.HTTP_303_SEE_OTHER) diff --git a/app/sep/apps/dipper/__init__.py b/app/sep/apps/dipper/__init__.py index 0035b36e8d..1adced2745 100644 --- a/app/sep/apps/dipper/__init__.py +++ b/app/sep/apps/dipper/__init__.py @@ -14,6 +14,5 @@ # along with this program. If not, see . from app.sep.apps.dipper.app import app -from app.sep.apps.dipper.routes import router -__all__ = ["app", "router"] +__all__ = ["app"] diff --git a/app/sep/apps/dipper/app.py b/app/sep/apps/dipper/app.py index 16444d924a..bb26294fc5 100644 --- a/app/sep/apps/dipper/app.py +++ b/app/sep/apps/dipper/app.py @@ -20,14 +20,12 @@ preview, execute) plus a static plugin schema — none of which map onto the framework's filename-keyed ``ScriptSource`` derived routes — so it is exported as a plain :class:`BaseApp` carrying its hand-written ``api_router`` unchanged, the same -shape as ``backup_mongo`` and the bespoke ``inventory``/``report`` apps. The legacy -Jinja UI is threaded as ``jinja_router``. +shape as ``backup_mongo`` and the bespoke ``inventory``/``report`` apps. """ from app.sep.apps.dipper.api_routes import router as api_router from app.sep.apps.dipper.constants import ARTIFACT_TYPE_DIPPER, DIPPER_PAYLOADS_DIR -from app.sep.apps.dipper.routes import router as jinja_router -from app.sep.apps.framework.base import BaseApp, StaticMount +from app.sep.apps.framework.base import BaseApp from app.sep.apps.nav_icons import NavIcon app = BaseApp( @@ -40,14 +38,6 @@ react_route="/dipper", nav_icon=NavIcon.SCIENCE, api_router=api_router, - jinja_router=jinja_router, artifact_base_dirs={ARTIFACT_TYPE_DIPPER: lambda: DIPPER_PAYLOADS_DIR}, uses_task_data=True, - static_mounts=( - StaticMount( - path="/static/dipper", - directory=DIPPER_PAYLOADS_DIR, - name="dipper_files", - ), - ), ) diff --git a/app/sep/apps/dipper/deps.py b/app/sep/apps/dipper/deps.py index 36a1da91d4..bd9424f2f3 100644 --- a/app/sep/apps/dipper/deps.py +++ b/app/sep/apps/dipper/deps.py @@ -17,24 +17,19 @@ import logging from collections.abc import Iterable -from typing import Annotated, Any +from typing import Any -from fastapi import Depends, Header, Request +from fastapi import Request from pydantic import ValidationError -from starlette import status from app.core.config import settings from app.core.exceptions import ( HTTPBadRequestException, HTTPNotFoundException, - HTTPRedirectException, HTTPUnprocessableEntityException, ) -from app.core.pagination import fetch_all_dict_items -from app.core.utils import remove_falsy_values_from_dict from app.core.utils.iterators import unique_everseen from app.inventory.models import ServiceTypeEnum -from app.sep.api.host_resolution import resolve_executor_name_by_address from app.sep.apps.dipper.constants import ( ARTIFACT_TYPE_DIPPER, CollectorTypeEnum, @@ -50,14 +45,11 @@ from app.sep.apps.framework.script_source import ScriptPreviewResponse from app.sep.clients.pmm import PMMRemoteAPI from app.sep.deps import ( - CreatedServiceDep, ExecutorHosts, get_pmm_api, # noqa: F401 -- re-exported for existing importers - InventoryAPI, PMMAPIDep, # noqa: F401 -- re-exported for existing importers ) from app.sep.inventory import CreatedService -from app.sep.middleware import messages from app.sep.snippets.models.snippet import ( BaseSnippetArgs, EXECUTOR_HOSTS_INPUT_NAME, @@ -154,45 +146,6 @@ async def get_dipper_script_preview( ) from exc -async def get_dipper_script( - service: CreatedServiceDep, - collector_type: CollectorTypeEnum = CollectorTypeEnum.ENVIRONMENT, -) -> DipperScript: - """Resolve and load the payload script for the selected service. - - :param service: The selected service. - :type service: CreatedServiceDep - :param collector_type: The collector type (environment or pmm). - :type collector_type: CollectorTypeEnum - :return: The DipperScript instance for the selected service. - :rtype: DipperScript - """ - filename = get_dipper_script_filename(service.type, collector_type) - try: - return await DipperScript.from_path(filename) - except FileNotFoundError as exc: - logger.warning("Missing dipper payload script %r", filename) - raise HTTPNotFoundException from exc - - -DipperScriptDep = Annotated[DipperScript, Depends(get_dipper_script)] - - -async def get_dipper_script_with_meta(script: DipperScriptDep) -> DipperScript: - """Load the payload script along with its metadata. - - This dependency ensures that the script's metadata is retrieved and up to date. - - :return: The DipperScript instance with updated metadata. - :rtype: DipperScript - """ - await script.update_meta() - return script - - -DipperScriptWithMetaDep = Annotated[DipperScript, Depends(get_dipper_script_with_meta)] - - def get_dipper_script_source(request: Request, script: DipperScript) -> str: """Return a signed URL for Nomad to download the dipper payload script. @@ -211,104 +164,6 @@ def get_dipper_script_source(request: Request, script: DipperScript) -> str: ) -async def get_dipper_execution_args( - request: Request, - script: DipperScriptWithMetaDep, - collector_type: CollectorTypeEnum = CollectorTypeEnum.ENVIRONMENT, - referer: Annotated[str | None, Header()] = None, -) -> BaseSnippetArgs: - """Validate execution parameters for the selected payload script. - - For PMM collector type, merges form values with settings.PMM defaults. - - :param request: The incoming HTTP request. - :type request: Request - :param script: The DipperScript instance with metadata. - :type script: DipperScriptWithMetaDep - :param collector_type: The collector type (environment or pmm). - :type collector_type: CollectorTypeEnum - :param referer: The referer URL. If None is specified, it defaults to the - dipper_index route. - :type referer: str | None - :return: The validated execution arguments. - :rtype: BaseSnippetArgs - :raises HTTPException: If validation fails or required parameters are missing. - """ - execution_model = script.get_execution_model() - async with request.form() as form: - form_data = dict(form) - logger.debug("Form data: %s", form_data) - - if collector_type == CollectorTypeEnum.PMM: - pmm = settings.PMM - pmm_server = form_data.get("pmmserver") or pmm.endpoint - if not pmm_server: - raise HTTPUnprocessableEntityException( - detail="PMM server URL is required." - " Provide it in the form or configure PMM__ENDPOINT.", - ) - form_data["pmmserver"] = pmm_server - form_data["apikey"] = form_data.get("apikey") or ( - pmm.api_key.get_secret_value() if pmm.api_key else None - ) - - try: - return execution_model.model_validate(remove_falsy_values_from_dict(form_data)) - except ValidationError as exc: - logger.debug("Invalid execution args", exc_info=True) - messages.from_validation_error( - request, - exc, - "Error executing script", - request.url.path, - exclude_types=("none_required",), - ) - raise HTTPRedirectException( - location=referer - or request.url_for("dipper_index").replace(query=request.url.query), - status_code=status.HTTP_303_SEE_OTHER, - ) from None - - -def resolve_executor_host_for_service( - executor_hosts: ExecutorHosts, service: CreatedServiceDep -) -> str | None: - """Resolve a Nomad client hostname for the given inventory service. - - Resolution order: - - 1. ``service.node.name`` -- direct match against Nomad node names - (fast path). - 2. ``service.node.address`` -- address-based lookup via - :func:`resolve_executor_name_by_address`, which covers the case - where the inventory display name differs from the Nomad client - node name but both refer to the same network address. - 3. ``service.name`` -- last-resort match against Nomad node names. - 4. ``None`` -- caller falls back to manual selection in the UI. - - :param executor_hosts: Mapping of Nomad node name to network address as - returned by ``GET /api/tasks/hosts/``. - :type executor_hosts: ExecutorHosts - :param service: The selected inventory service. - :type service: CreatedServiceDep - :return: The resolved Nomad node name, or ``None`` when no candidate - matches. - :rtype: str | None - """ - if service.node: - if service.node.name and service.node.name in executor_hosts: - return service.node.name - if service.node.address: - resolved = resolve_executor_name_by_address( - service.node.address, executor_hosts - ) - if resolved is not None: - return resolved - if service.name and service.name in executor_hosts: - return service.name - return None - - def resolve_pmm_executor_host(executor_hosts: ExecutorHosts) -> str | None: """Resolve Nomad node for PMM collector execution. @@ -415,35 +270,6 @@ async def fetch_pmm_node_service_names( ) -async def list_supported_services(inventory_api: InventoryAPI) -> list[dict]: - """Fetch the list of services that are supported by Dipper plugin. - - :param inventory_api: The InventoryAPI instance for making requests to the inventory - service. - :type inventory_api: InventoryAPI - :return: A list of dictionaries representing the supported services. - :rtype: list[dict] - """ - services = [] - for service_type in DIPPER_SCRIPT_BY_SERVICE_TYPE: - services.extend( - await fetch_all_dict_items( - lambda pagination, service_type=service_type: inventory_api.get( - "/services/", - params={ - "service_type": service_type.value, - **pagination.model_dump(), - }, - ) - ) - ) - logger.debug("Supported Dipper services: %s", services) - return services - - -SupportedDipperServices = Annotated[list[dict], Depends(list_supported_services)] - - def clean_dipper_api_args(raw_args: dict[str, Any]) -> dict[str, Any]: """Drop empty JSON form values without discarding meaningful falsy values.""" cleaned = {} @@ -553,34 +379,3 @@ async def build_dipper_execution_meta( sudo_default=body.sudo or False, ) return meta, script.execution_task_name - - -def get_dipper_execution_meta( - service: CreatedServiceDep, - script: DipperScriptWithMetaDep, - script_source: Annotated[str, Depends(get_dipper_script_source)], - execution_args: Annotated[BaseSnippetArgs, Depends(get_dipper_execution_args)], -) -> SnippetExecutionMeta: - """Create the task metadata payload for Dipper executions. - - :param service: The target service for the Dipper execution. - :type service: CreatedServiceDep - :param script: The Dipper payload script with metadata. - :type script: DipperScript - :param script_source: The signed URL to download the Dipper artifact. - :type script_source: str - :param execution_args: The execution arguments for the Dipper script. - :type execution_args: BaseSnippetArgs - :return: The prepared execution metadata. - :rtype: SnippetExecutionMeta - :raises HTTPBadRequestException: If no interpreter is configured for the script. - """ - return build_dipper_meta_from_args( - service, - script, - script_source, - execution_args, - ) - - -ExecutionMetaDep = Annotated[SnippetExecutionMeta, Depends(get_dipper_execution_meta)] diff --git a/app/sep/apps/dipper/routes.py b/app/sep/apps/dipper/routes.py deleted file mode 100644 index cec5b4f964..0000000000 --- a/app/sep/apps/dipper/routes.py +++ /dev/null @@ -1,222 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define routes for the Dipper plugin. - -These Jinja2 routes are deprecated. The JSON API equivalents live under -``/api/apps/dipper/`` and the React UI consumes them via -``frontend/packages/apps/dipper``. Every response from this router -carries the RFC 8594 ``Deprecation: true`` header and emits a WARNING on -hit; the React UI is at parity and the routes remain mounted only until -the Jinja layer is removed. -""" - -import logging - -from fastapi import APIRouter, HTTPException, Request, status -from fastapi.responses import HTMLResponse, RedirectResponse - -from app.sep.apps.dipper.constants import ( - CollectorTypeEnum, - DIPPER_SCRIPT_BY_SERVICE_TYPE, -) -from app.sep.apps.dipper.deps import ( - DipperScriptWithMetaDep, - ExecutionMetaDep, - get_dipper_script_filename, - get_pmm_form_defaults, - has_pmm_script, - resolve_executor_host_for_service, - resolve_pmm_executor_host, - SupportedDipperServices, -) -from app.sep.apps.dipper.models import DipperScript -from app.sep.apps.framework.deprecation import DeprecatedJinja2Route -from app.sep.config import sep_settings -from app.sep.deps import ( - DefaultContext, - ExecutorHosts, - ExecutorHostsCtx, - InventoryAPI, - IsAuthenticated, - TaskAPI, -) -from app.sep.inventory import CreatedService -from app.sep.middleware import messages -from app.sep.utils.jinja import syntax_highlight -from app.tasks.models import TaskHistoryStatusEnum - -logger = logging.getLogger(__name__) -router = APIRouter(route_class=DeprecatedJinja2Route) -templates = sep_settings.TEMPLATES - - -@router.get("/", dependencies=[IsAuthenticated], response_class=HTMLResponse) -async def dipper_index( - request: Request, - context: DefaultContext, - inventory_api: InventoryAPI, - supported_services: SupportedDipperServices, - tasks_api: TaskAPI, - executor_hosts: ExecutorHosts, - executor_hosts_ctx: ExecutorHostsCtx, - service_id: int | None = None, - collector_type: CollectorTypeEnum = CollectorTypeEnum.ENVIRONMENT, -) -> HTMLResponse: - """Render the Dipper page with service selection and execution history.""" - context |= { - "services": supported_services, - "selected_service_id": service_id, - "collector_type": collector_type, - } - - if service_id is None: - return templates.TemplateResponse( - request=request, name="dipper/index.html.j2", context=context - ) - - try: - service_data = await inventory_api.get(f"/services/{service_id}") - except HTTPException as exc: - messages.error(request, f"Could not load service {service_id}: {exc.detail}") - return templates.TemplateResponse( - request=request, name="dipper/index.html.j2", context=context - ) - - if service_data.get("type") not in {t.value for t in DIPPER_SCRIPT_BY_SERVICE_TYPE}: - messages.error(request, "Selected service type is not supported by Dipper") - return templates.TemplateResponse( - request=request, name="dipper/index.html.j2", context=context - ) - - selected_service = CreatedService.model_validate(service_data) - - script_filename = get_dipper_script_filename(selected_service.type, collector_type) - script = await DipperScript.from_path(script_filename, update_meta=True) - try: - preview = await script.get_preview() - context["script_preview"] = syntax_highlight( - preview.full_content, style="monokai", linenos=True, wrapcode=True - ) - except UnicodeDecodeError: - logger.exception("Could not decode dipper script for preview") - - default_executor_host = None - if collector_type == CollectorTypeEnum.PMM: - resolved_executor_host = resolve_pmm_executor_host(executor_hosts) - if resolved_executor_host is None: - messages.warning( - request, - "Failed to resolve PMM server to an execution target; please select manually.", - ) - default_executor_host = resolve_executor_host_for_service( - executor_hosts, selected_service - ) - else: - resolved_executor_host = resolve_executor_host_for_service( - executor_hosts, selected_service - ) - if resolved_executor_host is None: - messages.warning( - request, - "Could not map selected service to an execution target; please select manually.", - ) - - form_defaults = ( - get_pmm_form_defaults( - resolved_executor_host, - selected_service.name, - selected_service.node.name, - ) - if collector_type == CollectorTypeEnum.PMM - else None - ) - - snippet_filename = f"dipper/{selected_service.id}/{script.filename}" - response = await tasks_api.get( - f"/{script.execution_task_name}/history/", - params={"snippet_filename": snippet_filename}, - ) - history_tasks = response["items"] - for history in history_tasks: - try: - history["available_files"] = await tasks_api.get( - f"/history/{history['id']}/files/" - ) - except HTTPException: - history["available_files"] = [] - - response = await tasks_api.get( - f"/{script.execution_task_name}/history/", - params={ - "snippet_filename": snippet_filename, - "status": TaskHistoryStatusEnum.RUNNING, - }, - ) - context |= { - "is_pmm_script_available": has_pmm_script(selected_service.type), - "selected_service": selected_service, - "script": script, - "script_execution_url": str( - request.url_for("dipper_execute").include_query_params( - service_id=selected_service.id, collector_type=collector_type.value - ) - ), - "executor_hosts": frozenset( - { - ( - resolved_executor_host, - executor_hosts_ctx.display_name(resolved_executor_host), - ) - } - ) - if resolved_executor_host - else executor_hosts_ctx.as_form_hosts(), - "resolved_executor_host": resolved_executor_host, - "default_executor_host": default_executor_host, - "form_defaults": form_defaults, - "history_tasks": history_tasks, - "running_tasks": response["items"], - } - logger.debug("Context for Dipper index page: %s", context) - - return templates.TemplateResponse( - request=request, - name="dipper/index.html.j2", - context=context, - ) - - -@router.post("/execute", dependencies=[IsAuthenticated]) -async def dipper_execute( - request: Request, - tasks_api: TaskAPI, - execution_meta: ExecutionMetaDep, - script: DipperScriptWithMetaDep, - service_id: int, - collector_type: CollectorTypeEnum = CollectorTypeEnum.ENVIRONMENT, -) -> RedirectResponse: - """Dispatch a dipper task for the selected service.""" - await tasks_api.post( - f"/execute/{script.execution_task_name}", - json={"meta": execution_meta.model_dump(by_alias=True, exclude_none=True)}, - ) - messages.success(request, "Data collection started") - return RedirectResponse( - request.url_for("dipper_index").include_query_params( - service_id=service_id, collector_type=collector_type.value - ), - status_code=status.HTTP_303_SEE_OTHER, - ) diff --git a/app/sep/apps/framework/__init__.py b/app/sep/apps/framework/__init__.py index 31ca9342bd..c90ad3d503 100644 --- a/app/sep/apps/framework/__init__.py +++ b/app/sep/apps/framework/__init__.py @@ -21,7 +21,7 @@ TaskExecutionApp, Views, ) -from app.sep.apps.framework.base import AppPeriodicTask, BaseApp, StaticMount +from app.sep.apps.framework.base import AppPeriodicTask, BaseApp from app.sep.apps.framework.cascade import ( build_derived_payload, build_predecessor_chain_execute_body, @@ -153,7 +153,6 @@ "SectionLayout", "SectionRules", "ServiceRef", - "StaticMount", "TableRef", "TaskExecuteWrite", "TaskExecutionApp", diff --git a/app/sep/apps/framework/base.py b/app/sep/apps/framework/base.py index cc642b4876..345ffa710b 100644 --- a/app/sep/apps/framework/base.py +++ b/app/sep/apps/framework/base.py @@ -16,7 +16,6 @@ """Define ``BaseApp``, the uniform registry entry for a mounted SEP app.""" from collections.abc import Callable, Mapping -from dataclasses import dataclass from pathlib import Path from typing import Any, NamedTuple @@ -53,24 +52,6 @@ class AppPeriodicTask(NamedTuple): extra_kwargs: dict[str, Any] | None = None -@dataclass(frozen=True, slots=True) -class StaticMount: - """Declare one authenticated static mount for an app's payload directory. - - Collected from the registry in ``app/sep/main.py`` and mounted through - :class:`~app.sep.utils.static.AuthenticatedStaticFiles`, so a payload directory - is never served anonymously. - - :param path: The mount prefix (for example ``/static/snippets``). - :param directory: The directory served behind authentication. - :param name: The Starlette mount name used for reverse URL lookups. - """ - - path: str - directory: Path - name: str - - class BaseApp(BaseModel): """Represent a mounted SEP app as a uniform registry entry. @@ -87,7 +68,7 @@ class BaseApp(BaseModel): module-path-derived key; set explicitly, the author's value wins. :param name: The app's internal name. :param display_name: The human-facing label; defaults to ``name`` when absent. - :param uri_path: The Jinja mount prefix and sidebar link target. + :param uri_path: The app's URI prefix and sidebar link target. :param css_class: The sidebar CSS class. :param sidebar: Whether the app appears in the sidebar. :param group: The nav group key this app nests under; ``None`` renders it @@ -101,7 +82,6 @@ class BaseApp(BaseModel): :param enabled: The seed-time enabled default; stamped by the registry. :param custom_ui: Whether the app ships a bespoke React UI. :param api_router: The plugin's JSON ``APIRouter``, when it exposes one. - :param jinja_router: The plugin's Jinja ``APIRouter``. :param app_schema: The plugin's schema definition, aliased ``schema`` for authoring; ``None`` for legacy-wrapped apps. :param parent_key: The key of the parent app that structurally owns this one, @@ -130,10 +110,6 @@ class BaseApp(BaseModel): none. Resolved on every system periodic-task rebuild; each entry's ``schedule`` thunk is invoked then so hot intervals are re-read. Defaults to ``None``. - :param static_mounts: Authenticated static mounts for the app's payload - directories, collected from the registry and mounted in ``app/sep/main.py`` - through :class:`~app.sep.utils.static.AuthenticatedStaticFiles`. Defaults to - an empty tuple. :param uses_task_data: Whether the app's UI consumes the shared task-history routes (``/files``, ``/stream-logs``, ``/execution-events``). Any app in the registry declaring it mounts those routers for the whole deployment. @@ -157,7 +133,6 @@ class BaseApp(BaseModel): enabled: bool = True custom_ui: bool = False api_router: APIRouter | None = None - jinja_router: APIRouter | None = None app_schema: AppSchema | None = Field(default=None, alias="schema") parent_key: str | None = None child_apps: tuple["BaseApp", ...] = () @@ -168,7 +143,6 @@ class BaseApp(BaseModel): periodic_task_schedules: SkipValidation[ list[AppPeriodicTask] | Callable[[], list[AppPeriodicTask]] | None ] = None - static_mounts: tuple[StaticMount, ...] = () uses_task_data: bool = False @model_validator(mode="before") diff --git a/app/sep/apps/framework/connectivity.py b/app/sep/apps/framework/connectivity.py index 648633ade6..0a1a649ede 100644 --- a/app/sep/apps/framework/connectivity.py +++ b/app/sep/apps/framework/connectivity.py @@ -74,9 +74,9 @@ async def record_connectivity_warning( Delegate to :func:`app.sep.connectivity._fetch_connectivity_result` (cached via ``alru_cache``) and record the outcome in :data:`app.sep.connectivity._LATEST_RESULTS` via - :func:`app.sep.connectivity._record_latest_result`. The snapshot write - keeps :func:`app.sep.connectivity.annotate_tasks_with_connectivity` - reflecting JSON-path results the same way it reflects form-path ones. + :func:`app.sep.connectivity._record_latest_result`, which + :func:`app.sep.connectivity.get_latest_connectivity_result` reads back + synchronously. :param tasks_api: Authenticated Tasks API client. :param target: The Nomad node name. @@ -107,11 +107,10 @@ async def maybe_record_connectivity_warning( ) -> ConnectivityWarning | None: """Run :func:`record_connectivity_warning` when ``meta`` carries connectivity data. - Mirror the form-side :func:`app.sep.connectivity.maybe_check_connectivity` - guard semantics: short-circuit to ``None`` when ``check_connectivity`` is - ``False`` or when any of the required meta keys is missing or falsy. This - lets task-creation routes invoke the helper unconditionally without - inspecting ``meta`` themselves. + Short-circuit to ``None`` when ``check_connectivity`` is ``False`` or when + any of the required meta keys is missing or falsy. This lets task-creation + routes invoke the helper unconditionally without inspecting ``meta`` + themselves. :param tasks_api: Authenticated Tasks API client. :param meta: The ``task.data["meta"]`` mapping from the created task. diff --git a/app/sep/apps/framework/deprecation.py b/app/sep/apps/framework/deprecation.py deleted file mode 100644 index 8e1bd88f69..0000000000 --- a/app/sep/apps/framework/deprecation.py +++ /dev/null @@ -1,84 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define helpers for marking Jinja2 plugin routes as deprecated. - -Plugins migrating to the JSON API + React UI keep their legacy Jinja2 routes -mounted while the React UI matures. Mark every legacy route on an in-flight -plugin with this helper so clients see the RFC 8594 ``Deprecation`` response -header and so each hit is logged at WARNING. - -Apply once at router construction: - -.. code-block:: python - - from app.sep.apps.framework.deprecation import DeprecatedJinja2Route - - router = APIRouter(route_class=DeprecatedJinja2Route) -""" - -import logging -import warnings -from collections.abc import Callable, Coroutine -from typing import Any - -from fastapi import Request, Response -from fastapi.routing import APIRoute - -__all__ = ["DeprecatedJinja2Route"] - -logger = logging.getLogger(__name__) - - -class DeprecatedJinja2Route(APIRoute): - """Mark every response as deprecated and exclude operations from OpenAPI. - - Log a WARNING per request and set ``Deprecation: true`` on the - outgoing response, regardless of whether the endpoint returns a plain - value, a :class:`Response` subclass, or a template response. The - header mutation happens after the handler runs, so it survives - FastAPI's short-circuit for endpoints that return a ``Response`` - directly. - - Exclude routes from the generated OpenAPI schema - (``include_in_schema=False``) so the Swagger docs describe the - supported JSON API surface only. - """ - - def __init__(self, *args: Any, **kwargs: Any) -> None: - kwargs["include_in_schema"] = False - super().__init__(*args, **kwargs) - - def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]: - """Wrap the default route handler with deprecation logging and header. - - :return: A coroutine handler that logs a WARNING and stamps the - ``Deprecation: true`` header on the outgoing response. - :rtype: Callable[[Request], Coroutine[Any, Any, Response]] - """ - original_route_handler = super().get_route_handler() - - async def custom_route_handler(request: Request) -> Response: - message = ( - f"Jinja2 plugin route {request.url.path} is deprecated; " - "use the JSON API equivalent under /api/apps/" - ) - logger.warning(message) - warnings.warn(message, DeprecationWarning, stacklevel=2) - response = await original_route_handler(request) - response.headers["Deprecation"] = "true" - return response - - return custom_route_handler diff --git a/app/sep/apps/framework/registry.py b/app/sep/apps/framework/registry.py index c6d6db4be3..60bc3a04a3 100644 --- a/app/sep/apps/framework/registry.py +++ b/app/sep/apps/framework/registry.py @@ -377,7 +377,6 @@ def _synthesize_legacy_app(plugin: App, auto_key: str) -> BaseApp: nav_icon=plugin.nav_icon, enabled=plugin.enabled, api_router=api_router, - jinja_router=import_var(plugin.router_path), ) diff --git a/app/sep/apps/framework/templates/base/tests/test_contract.py.tmpl b/app/sep/apps/framework/templates/base/tests/test_contract.py.tmpl index e8316c3eb6..763dfb520f 100644 --- a/app/sep/apps/framework/templates/base/tests/test_contract.py.tmpl +++ b/app/sep/apps/framework/templates/base/tests/test_contract.py.tmpl @@ -24,7 +24,7 @@ from fastapi import APIRouter, FastAPI, status from fastapi.testclient import TestClient from app.core.auth.providers.casdoor.models import CasdoorUser -from app.sep.deps import get_api_authenticated_user, IsApiAuthenticated +from app.sep.deps import get_current_user, IsApiAuthenticated from app.sep.apps.<< name >>.app import app as << name >>_app _BASE = "/api/apps/<< name >>" @@ -38,7 +38,7 @@ def _client(user: CasdoorUser) -> TestClient: api_router.include_router(apps_router) fastapi_app = FastAPI() fastapi_app.include_router(api_router) - fastapi_app.dependency_overrides[get_api_authenticated_user] = lambda: user + fastapi_app.dependency_overrides[get_current_user] = lambda: user return TestClient(fastapi_app, raise_server_exceptions=False) diff --git a/app/sep/apps/inventory/__init__.py b/app/sep/apps/inventory/__init__.py index f07491ab52..1f49f8f89d 100644 --- a/app/sep/apps/inventory/__init__.py +++ b/app/sep/apps/inventory/__init__.py @@ -14,6 +14,5 @@ # along with this program. If not, see . from app.sep.apps.inventory.app import app -from app.sep.apps.inventory.routes import router -__all__ = ["app", "router"] +__all__ = ["app"] diff --git a/app/sep/apps/inventory/app.py b/app/sep/apps/inventory/app.py index e89e48f59c..5befb7d260 100644 --- a/app/sep/apps/inventory/app.py +++ b/app/sep/apps/inventory/app.py @@ -23,7 +23,6 @@ from app.sep.apps.framework.base import BaseApp from app.sep.apps.inventory.api_routes import router as api_router -from app.sep.apps.inventory.routes import router as jinja_router from app.sep.apps.inventory.schema import inventory_schema app = BaseApp( @@ -32,6 +31,5 @@ uri_path="/inventory", css_class="inventory", api_router=api_router, - jinja_router=jinja_router, schema=inventory_schema, ) diff --git a/app/sep/apps/inventory/routes.py b/app/sep/apps/inventory/routes.py deleted file mode 100644 index d967ca544e..0000000000 --- a/app/sep/apps/inventory/routes.py +++ /dev/null @@ -1,524 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define legacy Jinja routes for the Inventory app. - -These Jinja2 routes are deprecated. The JSON API equivalents live under -``/api/apps/inventory/`` and the React UI consumes them via -``frontend/packages/apps/inventory``. Every response from this router carries -the RFC 8594 ``Deprecation: true`` header and emits a WARNING on hit; the -routes remain mounted only until the Jinja layer is removed. -""" - -import logging -from typing import Annotated - -from fastapi import ( - APIRouter, - BackgroundTasks, - Form, - Header, - Request, - status, -) -from fastapi.responses import HTMLResponse, RedirectResponse - -from app.core.exceptions import HTTPBadGatewayException, HTTPBadRequestException -from app.core.pagination import fetch_all_dict_items -from app.core.security import get_internal_token -from app.sep.apps.framework.deprecation import DeprecatedJinja2Route -from app.sep.apps.inventory.connectivity import probe_service_connectivity -from app.sep.apps.inventory.constants import CONNECTABLE_SERVICE_TYPES -from app.sep.apps.inventory.deps import ( - build_available_syncers, - filter_syncers_by_name, - InternalTokenDep, - SyncersDep, -) -from app.sep.apps.inventory.models import InventorySyncScheduleCreateForm -from app.sep.apps.inventory.sync import ( - run_inventory_sync, - run_node_sync, - run_schema_sync, - run_service_sync, -) -from app.sep.config import sep_settings -from app.sep.crud import SyncItemManager -from app.sep.deps import ( - AVAILABLE_TIMEZONES, - CreatedNodeDep, - CreatedSchemaDep, - CreatedServiceDep, - CreatedTableDep, - DefaultContext, - InventoryAPI, - IsAuthenticated, - IsCsrfValidated, - SessionDep, - TaskAPI, -) -from app.sep.inventory import Node, Schema, Service, SourceEnum, Table -from app.sep.middleware import messages -from app.sep.models import SyncInventoryEntityTypeEnum - -logger = logging.getLogger(__name__) -router = APIRouter(route_class=DeprecatedJinja2Route) -templates = sep_settings.TEMPLATES - - -def _is_scheduled_sync_enabled() -> bool: - """Return whether scheduled inventory sync is enabled. - - The feature requires a non-empty internal service token; ``Settings`` - derives one from ``SECRET_KEY`` when it is unset, so this is effectively - always enabled in a booted deployment. - - :return: True if scheduled sync is enabled. - :rtype: bool - """ - return get_internal_token() is not None - - -@router.get("/", dependencies=[IsAuthenticated], response_class=HTMLResponse) -async def node_list( - session: SessionDep, - syncers: SyncersDep, - request: Request, - context: DefaultContext, - inventory_api: InventoryAPI, - tasks_api: TaskAPI, -) -> HTMLResponse: - """List Nodes.""" - context["inventory"] = await fetch_all_dict_items( - lambda pagination: inventory_api.get("/nodes/", params=pagination.model_dump()) - ) - context["source_enum"] = SourceEnum - context["sync_is_running"] = await SyncItemManager.sync_is_running( - session, - SyncInventoryEntityTypeEnum.INVENTORY, - ) - context["available_syncers"] = build_available_syncers( - syncers, - lambda syncer: syncer.can_sync_inventory(), - ) - context["can_sync"] = bool(context["available_syncers"]) - context["scheduled_sync_enabled"] = _is_scheduled_sync_enabled() - context["sync_schedules"] = await tasks_api.get("/inventory-sync/periodic/") - context["AVAILABLE_TIMEZONES"] = AVAILABLE_TIMEZONES - return templates.TemplateResponse( - request=request, - name="inventory/node-list.html.j2", - context=context, - ) - - -@router.post( - "/sync/", - dependencies=[IsAuthenticated, IsCsrfValidated], - include_in_schema=False, -) -async def sync_inventory( - syncers: SyncersDep, - background_tasks: BackgroundTasks, - internal_token: InternalTokenDep, - syncer_name: Annotated[str | None, Form(alias="syncer")] = None, -) -> RedirectResponse: - """Start inventory sync as a background task. - - :param syncers: The configured syncers from ``SyncersDep``. - :param background_tasks: FastAPI's background task scheduler. - :param internal_token: SEP-internal service token forwarded to the sync. - :param syncer_name: Optional single syncer to run instead of all of them. - :return: HTTP 303 redirect back to the inventory index. - """ - try: - selected = filter_syncers_by_name( - syncers, - syncer_name, - lambda syncer: syncer.can_sync_inventory(), - ) - except ValueError as exc: - raise HTTPBadRequestException(str(exc)) from exc - background_tasks.add_task(run_inventory_sync, internal_token, *selected) - return RedirectResponse("/inventory/", status_code=status.HTTP_303_SEE_OTHER) - - -@router.post( - "/schedule/", - dependencies=[IsAuthenticated, IsCsrfValidated], - include_in_schema=False, -) -async def schedule_create( - syncers: SyncersDep, - tasks_api: TaskAPI, - schedule: Annotated[InventorySyncScheduleCreateForm, Form()], - referer: Annotated[str, Header()] = "/inventory/", -) -> RedirectResponse: - """Attach a periodic schedule to the inventory-sync task. - - Validate the optional ``syncer`` field and the interval/crontab shape at - form-submit time using ``filter_syncers_by_name`` and explicit checks so - invalid input fails fast with a friendly redirect rather than silently - misfiring inside the worker. When ``syncer`` is unset the schedule runs - every configured syncer; when set it targets only that syncer. - - :param syncers: The configured syncers from ``SyncersDep``. - :param tasks_api: The Tasks API client. - :param schedule: The submitted attach form. - :param referer: The originating page used for the redirect on success. - :return: A 303 redirect back to the originating page. - :raises HTTPBadRequestException: If ``SEP_INTERNAL_TOKEN`` is not - configured, the syncer name is unknown, or both / neither schedule - mode is supplied. - """ - if not _is_scheduled_sync_enabled(): - raise HTTPBadRequestException( - "SEP_INTERNAL_TOKEN must be configured before attaching an " - "inventory-sync schedule.", - ) - if schedule.syncer: - try: - filter_syncers_by_name( - syncers, - schedule.syncer, - lambda candidate: candidate.can_sync_inventory(), - ) - except ValueError as exc: - raise HTTPBadRequestException(str(exc)) from exc - if schedule.interval and schedule.crontab: - raise HTTPBadRequestException( - "Cannot specify both interval and crontab; choose one schedule mode.", - ) - if not schedule.interval and not schedule.crontab: - raise HTTPBadRequestException( - "Either interval or crontab must be specified.", - ) - payload = schedule.to_periodic_task_payload() - logger.debug("Attaching inventory-sync schedule, %s", payload) - await tasks_api.post("/inventory-sync/periodic/", json=payload) - return RedirectResponse(referer, status_code=status.HTTP_303_SEE_OTHER) - - -@router.get("/{node_id}", dependencies=[IsAuthenticated], response_class=HTMLResponse) -async def node_detail( - session: SessionDep, - syncers: SyncersDep, - request: Request, - node: CreatedNodeDep, - context: DefaultContext, -) -> HTMLResponse: - """Retrieve Node Details.""" - context["node"] = node - context["sync_is_running"] = await SyncItemManager.sync_is_running( - session, - SyncInventoryEntityTypeEnum.NODE, - ) - context["available_syncers"] = build_available_syncers( - syncers, - lambda syncer: syncer.can_sync_node(node), - ) - context["can_sync"] = bool(context["available_syncers"]) - return templates.TemplateResponse( - request=request, - name="inventory/node-detail.html.j2", - context=context, - ) - - -@router.post("/{node_id}/sync/", dependencies=[IsAuthenticated, IsCsrfValidated]) -async def sync_node( - node: CreatedNodeDep, - syncers: SyncersDep, - background_tasks: BackgroundTasks, - internal_token: InternalTokenDep, - syncer_name: Annotated[str | None, Form(alias="syncer")] = None, -) -> RedirectResponse: - """Start node sync as a background task.""" - try: - selected = filter_syncers_by_name( - syncers, - syncer_name, - lambda syncer: syncer.can_sync_node(node), - ) - except ValueError as exc: - raise HTTPBadRequestException(str(exc)) from exc - background_tasks.add_task(run_node_sync, node, internal_token, *selected) - return RedirectResponse( - f"/inventory/{node.id}", - status_code=status.HTTP_303_SEE_OTHER, - ) - - -@router.post("/", dependencies=[IsAuthenticated, IsCsrfValidated]) -async def node_create( - inventory_api: InventoryAPI, - node_data: Annotated[Node, Form()], -) -> RedirectResponse: - """Create Node.""" - await inventory_api.post("/nodes/", json=node_data.model_dump(exclude={"services"})) - return RedirectResponse("/inventory/", status_code=status.HTTP_303_SEE_OTHER) - - -@router.post("/{node_id}/delete", dependencies=[IsAuthenticated, IsCsrfValidated]) -async def node_delete( - node_id: int, - inventory_api: InventoryAPI, -) -> RedirectResponse: - """Delete Node.""" - await inventory_api.delete(f"/nodes/{node_id}") - return RedirectResponse("/inventory/", status_code=status.HTTP_303_SEE_OTHER) - - -@router.get( - "/services/{service_id}", - dependencies=[IsAuthenticated], - response_class=HTMLResponse, -) -async def service_detail( - session: SessionDep, - syncers: SyncersDep, - request: Request, - service: CreatedServiceDep, - context: DefaultContext, -) -> HTMLResponse: - """Retrieve Service Details.""" - context["service"] = service - context["sync_is_running"] = await SyncItemManager.sync_is_running( - session, - SyncInventoryEntityTypeEnum.SERVICE, - ) - context["available_syncers"] = build_available_syncers( - syncers, - lambda syncer: syncer.can_sync_service(service), - ) - context["can_sync"] = bool(context["available_syncers"]) - context["can_check_connectivity"] = service.type in CONNECTABLE_SERVICE_TYPES - return templates.TemplateResponse( - request=request, - name="inventory/service-detail.html.j2", - context=context, - ) - - -@router.post( - "/services/{service_id}/sync/", dependencies=[IsAuthenticated, IsCsrfValidated] -) -async def sync_service( - service: CreatedServiceDep, - syncers: SyncersDep, - background_tasks: BackgroundTasks, - internal_token: InternalTokenDep, - syncer_name: Annotated[str | None, Form(alias="syncer")] = None, -) -> RedirectResponse: - """Start service sync as a background task.""" - try: - selected = filter_syncers_by_name( - syncers, - syncer_name, - lambda syncer: syncer.can_sync_service(service), - ) - except ValueError as exc: - raise HTTPBadRequestException(str(exc)) from exc - background_tasks.add_task(run_service_sync, service, internal_token, *selected) - return RedirectResponse( - f"/inventory/services/{service.id}", - status_code=status.HTTP_303_SEE_OTHER, - ) - - -@router.post( - "/services/{service_id}/check-connectivity/", - dependencies=[IsAuthenticated, IsCsrfValidated], -) -async def check_service_connectivity( - request: Request, - service: CreatedServiceDep, - tasks_api: TaskAPI, -) -> RedirectResponse: - """Check database connectivity for a service via Nomad.""" - redirect = RedirectResponse( - f"/inventory/services/{service.id}", - status_code=status.HTTP_303_SEE_OTHER, - ) - try: - result = await probe_service_connectivity(service, tasks_api) - except (HTTPBadGatewayException, HTTPBadRequestException) as exc: - messages.error(request, exc.detail) - return redirect - if result.success: - messages.success(request, f"Connectivity check passed for {service.name}") - else: - messages.error( - request, - f"Connectivity check failed for {service.name}: " - f"{result.error or 'Unknown error'}", - ) - return redirect - - -@router.post("/{node_id}/services/", dependencies=[IsAuthenticated, IsCsrfValidated]) -async def service_create_for_node( - node_id: int, - inventory_api: InventoryAPI, - service_data: Annotated[Service, Form()], -) -> RedirectResponse: - """Create Service for Node.""" - await inventory_api.post( - f"/nodes/{node_id}/services/", - json=service_data.model_dump(exclude={"schemas"}), - ) - return RedirectResponse( - f"/inventory/{node_id}", - status_code=status.HTTP_303_SEE_OTHER, - ) - - -@router.post( - "/services/{service_id}/delete", dependencies=[IsAuthenticated, IsCsrfValidated] -) -async def service_delete( - inventory_api: InventoryAPI, - service: CreatedServiceDep, -) -> RedirectResponse: - """Delete Service.""" - await inventory_api.delete(f"/services/{service.id}") - return RedirectResponse( - f"/inventory/{service.node_id}", - status_code=status.HTTP_303_SEE_OTHER, - ) - - -@router.get( - "/schemas/{schema_id}", - dependencies=[IsAuthenticated], - response_class=HTMLResponse, -) -async def schema_detail( - session: SessionDep, - syncers: SyncersDep, - request: Request, - schema: CreatedSchemaDep, - context: DefaultContext, -) -> HTMLResponse: - """Retrieve Schema Details.""" - context["schema"] = schema - context["sync_is_running"] = await SyncItemManager.sync_is_running( - session, - SyncInventoryEntityTypeEnum.SCHEMA, - ) - context["available_syncers"] = build_available_syncers( - syncers, - lambda syncer: syncer.can_sync_schema(schema), - ) - context["can_sync"] = bool(context["available_syncers"]) - return templates.TemplateResponse( - request=request, - name="inventory/schema-detail.html.j2", - context=context, - ) - - -@router.post( - "/schemas/{schema_id}/sync/", dependencies=[IsAuthenticated, IsCsrfValidated] -) -async def sync_schema( - schema: CreatedSchemaDep, - syncers: SyncersDep, - background_tasks: BackgroundTasks, - internal_token: InternalTokenDep, - syncer_name: Annotated[str | None, Form(alias="syncer")] = None, -) -> RedirectResponse: - """Start schema sync as a background task.""" - try: - selected = filter_syncers_by_name( - syncers, - syncer_name, - lambda syncer: syncer.can_sync_schema(schema), - ) - except ValueError as exc: - raise HTTPBadRequestException(str(exc)) from exc - background_tasks.add_task(run_schema_sync, schema, internal_token, *selected) - return RedirectResponse( - f"/inventory/schemas/{schema.id}", - status_code=status.HTTP_303_SEE_OTHER, - ) - - -@router.post( - "/services/{service_id}/schemas/", dependencies=[IsAuthenticated, IsCsrfValidated] -) -async def schema_create_for_service( - service_id: int, - inventory_api: InventoryAPI, - schema_data: Annotated[Schema, Form()], -) -> RedirectResponse: - """Create Schema for Service.""" - await inventory_api.post( - f"/services/{service_id}/schemas/", - json=schema_data.model_dump(exclude={"tables"}), - ) - return RedirectResponse( - f"/inventory/services/{service_id}", - status_code=status.HTTP_303_SEE_OTHER, - ) - - -@router.post( - "/schemas/{schema_id}/delete", dependencies=[IsAuthenticated, IsCsrfValidated] -) -async def schema_delete( - inventory_api: InventoryAPI, - schema: CreatedSchemaDep, -) -> RedirectResponse: - """Delete Schema.""" - await inventory_api.delete(f"/schemas/{schema.id}") - return RedirectResponse( - f"/inventory/services/{schema.service_id}", - status_code=status.HTTP_303_SEE_OTHER, - ) - - -@router.post( - "/schemas/{schema_id}/tables/", dependencies=[IsAuthenticated, IsCsrfValidated] -) -async def table_create_for_schema( - schema_id: int, - inventory_api: InventoryAPI, - table_data: Annotated[Table, Form()], -) -> RedirectResponse: - """Create Table for Schema.""" - await inventory_api.post( - f"/schemas/{schema_id}/tables/", - json=table_data.model_dump(), - ) - return RedirectResponse( - f"/inventory/schemas/{schema_id}", - status_code=status.HTTP_303_SEE_OTHER, - ) - - -@router.post( - "/tables/{table_id}/delete", dependencies=[IsAuthenticated, IsCsrfValidated] -) -async def table_delete( - inventory_api: InventoryAPI, - table: CreatedTableDep, -) -> RedirectResponse: - """Delete Table.""" - await inventory_api.delete(f"/tables/{table.id}") - return RedirectResponse( - f"/inventory/schemas/{table.schema_id}", - status_code=status.HTTP_303_SEE_OTHER, - ) diff --git a/app/sep/apps/mysql_backups/app.py b/app/sep/apps/mysql_backups/app.py index f3149b0ccf..b214095a41 100644 --- a/app/sep/apps/mysql_backups/app.py +++ b/app/sep/apps/mysql_backups/app.py @@ -25,8 +25,7 @@ stamping ``backup_type`` / ``hostname`` on list, detail, and create; delete is the framework's plain default. The display fields (``display_name`` / ``uri_path`` / ``css_class``) are supplied here because ``settings.yaml`` no longer carries -them. The Jinja UI router is threaded explicitly as ``jinja_router``; the -registry does not. The restore subpackage is a structurally-bound child app +them. The restore subpackage is a structurally-bound child app declared via ``child_apps`` (key ``mysql_backups/restore``), so it is mounted and toggled with this parent rather than as an independent ``settings.yaml`` entry or a sub-router here. @@ -45,7 +44,6 @@ from app.sep.apps.mysql_backups.forms import BackupCreate, BackupTaskResponse, OWNER from app.sep.apps.mysql_backups.recorder import RUN_RESULT_RECORDER from app.sep.apps.mysql_backups.restore.app import app as restore_app -from app.sep.apps.mysql_backups.routes import router as jinja_router from app.sep.apps.mysql_backups.spec import build_backup_spec from app.sep.apps.mysql_backups.views import mysql_backups_views from app.sep.apps.nav_icons import NavIcon @@ -78,7 +76,6 @@ route_segment="restores", ), ), - jinja_router=jinja_router, extra_routes=(catalog_router,), child_apps=(restore_app,), ) diff --git a/app/sep/apps/mysql_backups/deps.py b/app/sep/apps/mysql_backups/deps.py index 8ef2253cdb..23e8289d17 100644 --- a/app/sep/apps/mysql_backups/deps.py +++ b/app/sep/apps/mysql_backups/deps.py @@ -20,69 +20,26 @@ from typing import Annotated, Any import yaml -from fastapi import Depends, Form, Query +from fastapi import Depends, Query from app.core.exceptions import HTTPNotFoundException from app.inventory.models import ServiceTypeEnum -from app.sep.apps.framework import build_default_task_response, make_task_dep -from app.sep.apps.framework.spec import ( - assemble_envelope, - resolve_refs, -) -from app.sep.apps.mysql_backups.forms import BackupCreate, BackupTaskResponse, OWNER +from app.sep.apps.framework import build_default_task_response +from app.sep.apps.mysql_backups.forms import BackupTaskResponse from app.sep.apps.mysql_backups.models import ( BackupType, CatalogServiceKey, extract_backup_type_marker, ) -from app.sep.apps.mysql_backups.recorder import RUN_RESULT_RECORDER from app.sep.apps.mysql_backups.restore.deps import UNKNOWN_SERVICE_SENTINEL -from app.sep.apps.mysql_backups.spec import build_backup_spec from app.sep.apps.shared.backups.edit_form import parse_server_list_config -from app.sep.deps import ( - DefaultContext, - ExecutorHostsCtx, - get_tasks_context, - InventoryAPI, - TaskAPI, -) +from app.sep.deps import InventoryAPI from app.sep.inventory import CreatedService -from app.tasks.models import Task, TaskHistoryStatusEnum, TaskWrite +from app.tasks.models import Task, TaskHistoryStatusEnum logger = logging.getLogger(__name__) -async def build_backup_task_payload( - form: Annotated[BackupCreate, Form()], - inventory_api: InventoryAPI, -) -> TaskWrite: - """Build the backup task payload from a form-urlencoded body. - - The legacy Jinja form path's payload dependency. Resolves the form's - reference fields and feeds the shared pure - :func:`~app.sep.apps.mysql_backups.spec.build_backup_spec` through the - framework's ``assemble_envelope``, the same pair the model-first JSON create - route uses — so a form-created task's Nomad payload stays byte-identical to a - JSON-created one. - - :param form: The form data for the Backups creation. - :type form: BackupCreate - :param inventory_api: The Inventory API to resolve the service reference. - :type inventory_api: InventoryAPI - :return: A fully constructed ``TaskWrite`` object. - :rtype: TaskWrite - """ - resolved = await resolve_refs(form, inventory_api) - return assemble_envelope( - build_backup_spec(form, resolved), - resolved, - name=form.task_name, - owner=OWNER, - alert_on_fail=form.alert_on_fail, - run_result_recorder=RUN_RESULT_RECORDER, - ) - - def parse_backup_task_data(task: dict[str, Any]) -> dict[str, Any]: """Parse backup task data for editing. @@ -120,9 +77,6 @@ def parse_backup_task_data(task: dict[str, Any]) -> dict[str, Any]: ) -BackupGeneratedTask = Annotated[TaskWrite, Depends(build_backup_task_payload)] - - async def resolve_mysql_service( service_id: int, inventory_api: InventoryAPI ) -> CreatedService: @@ -215,11 +169,6 @@ async def resolve_optional_catalog_service_key( ] -get_backups_task = make_task_dep(OWNER) - -BackupsTask = Annotated[Task, Depends(get_backups_task)] - - def _extract_backup_type_from_task(task: Task) -> BackupType | None: """Read ``BACKUP_TYPE`` out of the task's YAML config as a typed value, if present. @@ -276,67 +225,3 @@ def build_mysql_backups_api_task_response( "last_updated_by": mapping.get(task.last_updated_by, task.last_updated_by), }, ) - - -def get_backups_task_info(task: dict[str, Any]) -> dict[str, Any]: - """Extract relevant information from a task for the Backups plugin. - - Processes the task data to extract hostname and tables information. - - :param task: The task data retrieved from the Tasks API. - :type task: dict[str, Any] - :return: A dictionary containing hostname and tables information. - :rtype: dict[str, Any] - """ - data = task["data"] - meta = data["meta"] - task_config = yaml.safe_load(meta["config"]) - backup_server = task_config["SERVER_LIST"][0] - - return { - "hostname": meta["target"], - "host": backup_server.get("HOST"), - "port": backup_server.get("PORT"), - "upload": ", ".join(backup_server.get("UPLOAD")), - "backup_type": BackupType(backup_server.get("BACKUP_TYPE")).name, - "created_by": task.get("created_by"), - "last_updated_by": task.get("last_updated_by"), - } - - -async def get_backups_index_context( - inventory_api: InventoryAPI, - tasks_api: TaskAPI, - context: DefaultContext, - executor_hosts_ctx: ExecutorHostsCtx, -) -> dict[str, Any]: - """Assemble the context for the Backups plugin index view. - - Retrieves MySQL services and associated tasks, organizing them based on their - execution status. Integrates this information into the default context for - rendering in templates. - - :param inventory_api: The Inventory API client for fetching service and schema data. - :type inventory_api: InventoryAPI - :param tasks_api: The TaskAPI client for fetching task data. - :type tasks_api: TaskAPI - :param context: The default context to be updated with Backups-specific information. - :type context: DefaultContext - :param executor_hosts_ctx: The executor hosts context for the Backups tasks. - :type executor_hosts_ctx: ExecutorHostsCtx - :return: An updated context dictionary containing Backups-related data. - :rtype: dict[str, Any] - """ - return await get_tasks_context( - inventory_api, - tasks_api, - get_backups_task_info, - executor_hosts_ctx, - context, - OWNER, - service_type=ServiceTypeEnum.MYSQL, - alert_on_fail_default=True, - ) - - -BackupsIndexContext = Annotated[dict[str, Any], Depends(get_backups_index_context)] diff --git a/app/sep/apps/mysql_backups/restore/app.py b/app/sep/apps/mysql_backups/restore/app.py index 23a6820733..e3e6f842e0 100644 --- a/app/sep/apps/mysql_backups/restore/app.py +++ b/app/sep/apps/mysql_backups/restore/app.py @@ -18,9 +18,8 @@ The registry discovers this app through ``mysql_backups``'s ``child_apps`` rather than a ``settings.yaml`` entry, so it is mounted and toggled exactly with its parent under the explicit scoped key ``mysql_backups/restore``, serving its -derived JSON router at ``/api/apps/mysql_backups/restore/`` while the existing -Jinja UI keeps serving at ``/mysql_backups/restores/`` via the threaded -``jinja_router``. Because a restore legitimately has no destination service for +derived JSON router at ``/api/apps/mysql_backups/restore/``. Because a restore +legitimately has no destination service for XtraBackup / Binlog and tolerates a 404 fallback, the create payload is built by the ``payload_builder`` escape hatch (:func:`~app.sep.apps.mysql_backups.restore.deps.build_restore_payload`) @@ -47,7 +46,6 @@ RestoreCreate, RestoresResponse, ) -from app.sep.apps.mysql_backups.restore.routes import router as jinja_router from app.sep.apps.mysql_backups.restore.views import restore_views app = TaskExecutionApp( @@ -70,5 +68,4 @@ pagination=make_pagination_dep(max_limit=DEFAULT_PAGINATION_LIMIT), capabilities=AppCapabilities(update=True, delete=True), list_filter=ListFilterConfig(status=True), - jinja_router=jinja_router, ) diff --git a/app/sep/apps/mysql_backups/restore/deps.py b/app/sep/apps/mysql_backups/restore/deps.py index ca2a4e18b7..8b1d3882f6 100644 --- a/app/sep/apps/mysql_backups/restore/deps.py +++ b/app/sep/apps/mysql_backups/restore/deps.py @@ -19,33 +19,22 @@ from typing import Annotated, Any import yaml -from fastapi import Body, Depends, Form +from fastapi import Body from app.core.exceptions import ( HTTPNotFoundException, HTTPUnprocessableEntityException, ) from app.inventory.models import ServiceTypeEnum -from app.sep.apps.framework import build_default_task_response, make_task_dep +from app.sep.apps.framework import build_default_task_response from app.sep.apps.framework.spec import stamp_form_input from app.sep.apps.mysql_backups.models import BackupType -from app.sep.apps.mysql_backups.restore.models import ( - OWNER, - RestoreCreate, - RestoresResponse, -) +from app.sep.apps.mysql_backups.restore.models import RestoreCreate, RestoresResponse from app.sep.apps.mysql_backups.restore.spec import ( build_restore_spec, RestoreResolved, ) -from app.sep.deps import ( - DefaultContext, - ExecutorHostsCtx, - get_created_entity, - get_tasks_context, - InventoryAPI, - TaskAPI, -) +from app.sep.deps import get_created_entity, InventoryAPI from app.sep.models import SyncInventoryEntityTypeEnum from app.tasks.models import Task, TaskHistoryStatusEnum, TaskWrite @@ -149,24 +138,6 @@ async def build_restore_payload( return write -async def build_restore_task_payload( - form: Annotated[RestoreCreate, Form()], - inventory_api: InventoryAPI, -) -> TaskWrite: - """Build the restore task payload for the legacy Jinja form path. - - Resolves the form's references and feeds the shared pure - :func:`build_restore_spec`, the same pair the JSON create route uses, so a - form-created task's payload is byte-identical to a JSON-created one. - - :param form: The form-encoded restore create body. - :param inventory_api: The Inventory API used to resolve references. - :return: The restore ``TaskWrite``. - """ - resolved = await resolve_restore_entities(form, inventory_api) - return build_restore_spec(form, resolved) - - def _extract_restore_config(task: Task) -> tuple[BackupType | None, Any, Any]: """Read backup type and destination host/port out of a restore task's config. @@ -259,73 +230,3 @@ def parse_restore_task_data(task: dict[str, Any]) -> dict[str, Any]: ) return result - - -RestoreGeneratedTask = Annotated[TaskWrite, Depends(build_restore_task_payload)] - - -get_restores_task = make_task_dep(OWNER) - -RestoresTask = Annotated[Task, Depends(get_restores_task)] - - -def get_restores_task_info(task: dict[str, Any]) -> dict[str, Any]: - """Extract relevant information from a task for the Restores plugin. - - Processes the task data to extract hostname and tables information. - - :param task: The task data retrieved from the Tasks API. - :type task: dict[str, Any] - :return: A dictionary containing hostname and tables information. - :rtype: dict[str, Any] - """ - data = task["data"] - meta = data["meta"] - task_config = yaml.safe_load(meta["config"]) - restore_server = task_config["SERVER_LIST"][0] - - return { - "hostname": meta["target"], - "host": restore_server.get("HOST"), - "port": restore_server.get("PORT") or 3306, - "backup_type": BackupType(restore_server.get("BACKUP_TYPE")).name, - "created_by": task.get("created_by"), - "last_updated_by": task.get("last_updated_by"), - } - - -async def get_restores_index_context( - inventory_api: InventoryAPI, - tasks_api: TaskAPI, - context: DefaultContext, - executor_hosts_ctx: ExecutorHostsCtx, -) -> dict[str, Any]: - """Assemble the context for the Restores plugin index view. - - Retrieves MySQL services and associated tasks, organizing them based on their - execution status. Integrates this information into the default context for - rendering in templates. - - :param inventory_api: The Inventory API client for fetching service and schema data. - :type inventory_api: InventoryAPI - :param tasks_api: The TaskAPI client for fetching task data. - :type tasks_api: TaskAPI - :param context: The default context to be updated with Restores-specific information. - :type context: DefaultContext - :param executor_hosts_ctx: The executor hosts context for the Restore tasks. - :type executor_hosts_ctx: ExecutorHostsCtx - :return: An updated context dictionary containing Restores-related data. - :rtype: dict[str, Any] - """ - return await get_tasks_context( - inventory_api, - tasks_api, - get_restores_task_info, - executor_hosts_ctx, - context, - OWNER, - service_type=ServiceTypeEnum.MYSQL, - ) - - -RestoresIndexContext = Annotated[dict[str, Any], Depends(get_restores_index_context)] diff --git a/app/sep/apps/mysql_backups/restore/routes.py b/app/sep/apps/mysql_backups/restore/routes.py deleted file mode 100644 index 3cf94fa3b0..0000000000 --- a/app/sep/apps/mysql_backups/restore/routes.py +++ /dev/null @@ -1,225 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define routes for the restores plugin.""" - -import logging -from typing import Annotated - -import yaml -from fastapi import APIRouter, Form, HTTPException, Request, status -from fastapi.responses import HTMLResponse, RedirectResponse -from pydantic import FutureDatetime - -from app.core.alerts.config import alert_settings -from app.core.pagination import fetch_all_dict_items -from app.inventory.models import ServiceTypeEnum -from app.sep.apps.framework.deprecation import DeprecatedJinja2Route -from app.sep.apps.mysql_backups.models import BackupType -from app.sep.apps.mysql_backups.restore.deps import ( - parse_restore_task_data, - RestoreGeneratedTask, - RestoresIndexContext, - RestoresTask, -) -from app.sep.config import sep_settings -from app.sep.deps import ( - DefaultContext, - ExecutorHostsCtx, - get_chainable_tasks, - InventoryAPI, - IsAuthenticated, - IsCsrfValidated, - TaskAPI, -) -from app.tasks.models import TaskHistoryStatusEnum - -logger = logging.getLogger(__name__) -router = APIRouter(route_class=DeprecatedJinja2Route) -templates = sep_settings.TEMPLATES - - -@router.get("/", dependencies=[IsAuthenticated], response_class=HTMLResponse) -async def restores_index( - request: Request, - context: RestoresIndexContext, -) -> HTMLResponse: - """Homepage of restores plugin.""" - return templates.TemplateResponse( - request=request, - name="mysql_backups/restore/index.html.j2", - context=context, - ) - - -@router.post( - "/", dependencies=[IsAuthenticated, IsCsrfValidated], response_class=HTMLResponse -) -async def restores_create( - request: Request, - task: RestoreGeneratedTask, - task_api: TaskAPI, -) -> RedirectResponse: - """Create new restores task.""" - logger.debug("Create restores task: %s", task) - await task_api.post( - "/", - json=task.model_dump(), - ) - task_path = request.url_for("restores_detail", task_name=task.name) - return RedirectResponse( - task_path, - status_code=status.HTTP_303_SEE_OTHER, - ) # TODO: Custom redirect class # noqa: TD002, TD003 - - -@router.get("/{task_name}", dependencies=[IsAuthenticated], response_class=HTMLResponse) -async def restores_detail( - task: RestoresTask, - request: Request, - context: DefaultContext, - inventory_api: InventoryAPI, - tasks_api: TaskAPI, - executor_hosts_ctx: ExecutorHostsCtx, -) -> HTMLResponse: - """Retrieve restores task.""" - data = task.data - meta = data["meta"] - task_config = yaml.safe_load(meta["config"]) - server_config = task_config["SERVER_LIST"][0] - - parsed_task_data = parse_restore_task_data(task.model_dump()) - - task_data = { - "name": task.name, - "created_at": task.created_at, - "updated_at": task.updated_at, - "created_by": task.created_by, - "last_updated_by": task.last_updated_by, - "hostname": meta["target"], - "meta": meta, - "host": server_config.get("dest_host"), - "port": server_config.get("dest_port"), - "database": server_config.get("database"), - "restore_type": BackupType(server_config["BACKUP_TYPE"]).name, - "delete_url": request.url_for("restores_delete", task_name=task.name), - "is_edit_enabled": not task.protected, - "alert_on_fail": task.alert_on_fail, - } - - task_data.update(parsed_task_data) - - context["task"] = task_data - response = await tasks_api.get(f"/{task.name}/history/") - context["history"] = response["items"] - response = await tasks_api.get( - f"/{task.name}/history/", params={"status": TaskHistoryStatusEnum.RUNNING} - ) - context["running_tasks"] = response["items"] - context["stats"] = await tasks_api.get(f"/stats/{task.name}") - - context["executor_hosts"] = executor_hosts_ctx.with_host( - task_data["hostname"] - ).as_template_list() - - try: - context["services"] = await fetch_all_dict_items( - lambda pagination: inventory_api.get( - "/services/", - params={ - "service_type": ServiceTypeEnum.MYSQL, - **pagination.model_dump(), - }, - ) - ) - except HTTPException: - context["services"] = [] - - context["alert_on_fail_default"] = task.alert_on_fail - context["alert_on_fail_available"] = bool(alert_settings.PROVIDERS) - context["chainable_tasks"] = await get_chainable_tasks( - tasks_api, task.owner, meta["target"], task.name - ) - - return templates.TemplateResponse( - request=request, - name="mysql_backups/restore/details.html.j2", - context=context, - ) - - -@router.post( - "/{task_name}", - dependencies=[IsAuthenticated, IsCsrfValidated], - response_class=RedirectResponse, -) -async def restores_execute( - request: Request, - task: RestoresTask, - tasks_api: TaskAPI, - eta: Annotated[FutureDatetime | None, Form()] = None, - chain_task_names: Annotated[list[str] | None, Form()] = None, - chain_on_failure: Annotated[bool | None, Form()] = None, -) -> RedirectResponse: - """Execute restores task.""" - await tasks_api.post( - f"/execute/{task.name}", - json={ - "eta": eta, - "chain_task_names": chain_task_names, - "chain_on_failure": chain_on_failure, - }, - ) - task_path = request.url_for("restores_detail", task_name=task.name) - return RedirectResponse(task_path, status_code=status.HTTP_303_SEE_OTHER) - - -@router.post( - "/{task_name}/update", - dependencies=[IsAuthenticated, IsCsrfValidated], - response_class=RedirectResponse, -) -async def restores_update( - request: Request, - task_name: str, - updated_task: RestoreGeneratedTask, - tasks_api: TaskAPI, -) -> RedirectResponse: - """Update restores task.""" - logger.debug("Updating restores task: %s", updated_task) - await tasks_api.put( - f"/{task_name}", - json=updated_task.model_dump(), - ) - return RedirectResponse( - request.url_for("restores_detail", task_name=updated_task.name), - status_code=status.HTTP_303_SEE_OTHER, - ) - - -@router.post( - "/{task_name}/delete", - dependencies=[IsAuthenticated, IsCsrfValidated], - response_class=RedirectResponse, -) -async def restores_delete( - request: Request, - task: RestoresTask, - tasks_api: TaskAPI, -) -> RedirectResponse: - """Delete restores task.""" - await tasks_api.delete(f"/{task.name}") - task_path = request.url_for("restores_index") - return RedirectResponse(task_path, status_code=status.HTTP_303_SEE_OTHER) diff --git a/app/sep/apps/mysql_backups/routes.py b/app/sep/apps/mysql_backups/routes.py deleted file mode 100644 index 3ab7b1e776..0000000000 --- a/app/sep/apps/mysql_backups/routes.py +++ /dev/null @@ -1,258 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define routes for the backups plugin.""" - -import logging -from typing import Annotated - -import yaml -from fastapi import APIRouter, Form, HTTPException, Request, status -from fastapi.responses import HTMLResponse, RedirectResponse -from pydantic import FutureDatetime - -from app.core.alerts.config import alert_settings -from app.core.pagination import fetch_all_dict_items -from app.inventory.models import ServiceTypeEnum -from app.sep.apps.framework.deprecation import DeprecatedJinja2Route -from app.sep.apps.mysql_backups.deps import ( - BackupGeneratedTask, - BackupsIndexContext, - BackupsTask, - parse_backup_task_data, -) -from app.sep.apps.mysql_backups.models import BackupType -from app.sep.config import sep_settings -from app.sep.connectivity import maybe_check_connectivity -from app.sep.deps import ( - CheckConnectivityFlag, - DefaultContext, - ExecutorHostsCtx, - get_chainable_tasks, - HasNoConflictedRunningTasks, - InventoryAPI, - IsAuthenticated, - IsCsrfValidated, - TaskAPI, -) -from app.tasks.models import TaskHistoryStatusEnum - -logger = logging.getLogger(__name__) -# Jinja2 UI deprecated — React replacement at /apps/mysql_backups. -router = APIRouter(route_class=DeprecatedJinja2Route) -templates = sep_settings.TEMPLATES - - -@router.get("/", dependencies=[IsAuthenticated], response_class=HTMLResponse) -async def mysql_backups_index( - request: Request, - context: BackupsIndexContext, -) -> HTMLResponse: - """Homepage of backups plugin.""" - return templates.TemplateResponse( - request=request, - name="mysql_backups/backup/index.html.j2", - context=context, - ) - - -@router.get("/docs", dependencies=[IsAuthenticated], response_class=HTMLResponse) -async def mysql_backups_docs(request: Request, context: DefaultContext) -> HTMLResponse: - """Standalone documentation page for backup configuration.""" - return templates.TemplateResponse( - request=request, - name="mysql_backups/backup/docs.html.j2", - context=context, - ) - - -@router.post( - "/", dependencies=[IsAuthenticated, IsCsrfValidated], response_class=HTMLResponse -) -async def mysql_backups_create( - request: Request, - task: BackupGeneratedTask, - task_api: TaskAPI, - *, - check_connectivity: CheckConnectivityFlag, -) -> RedirectResponse: - """Create new backups task.""" - logger.debug("Create backups task: %s", task) - await task_api.post( - "/", - json=task.model_dump(), - ) - await maybe_check_connectivity( - request, - task_api, - task.data.get("meta", {}), - check_connectivity=check_connectivity, - ) - task_path = request.url_for("mysql_backups_detail", task_name=task.name) - return RedirectResponse( - task_path, - status_code=status.HTTP_303_SEE_OTHER, - ) # TODO: Custom redirect class # noqa: TD002, TD003 - - -@router.get("/{task_name}", dependencies=[IsAuthenticated], response_class=HTMLResponse) -async def mysql_backups_detail( - task: BackupsTask, - request: Request, - context: DefaultContext, - inventory_api: InventoryAPI, - tasks_api: TaskAPI, - executor_hosts_ctx: ExecutorHostsCtx, -) -> HTMLResponse: - """Retrieve backups task.""" - data = task.data - meta = data["meta"] - decoded_entities = task.anonymized_entities - task_config = yaml.safe_load(meta["config"]) - server_config = task_config["SERVER_LIST"][0] - - parsed_task_data = parse_backup_task_data(task.model_dump()) - - host = server_config["HOST"] - backup_type = BackupType(server_config["BACKUP_TYPE"]).name - db_host_for_display = ( - meta["target"] - if host in ("localhost", "127.0.0.1") and server_config["BACKUP_TYPE"] == "X" - else host - ) - - task_data = { - "name": task.name, - "created_at": task.created_at, - "updated_at": task.updated_at, - "created_by": task.created_by, - "last_updated_by": task.last_updated_by, - "hostname": meta["target"], - "meta": meta, - "host": host, - "db_host_for_display": db_host_for_display, - "executor_host_ip": executor_hosts_ctx.hosts.get(meta["target"]), - "port": server_config.get("PORT") or 3306, - "backup_type": backup_type, - "entities": {entity.name: entity.value for entity in decoded_entities}, - "delete_url": request.url_for("mysql_backups_delete", task_name=task.name), - "config": task_config.get("ALL_SERVERS", {}), - "is_edit_enabled": not task.protected, - "alert_on_fail": task.alert_on_fail, - } - - task_data.update(parsed_task_data) - - context["task"] = task_data - response = await tasks_api.get(f"/{task.name}/history/") - context["history"] = response["items"] - response = await tasks_api.get( - f"/{task.name}/history/", params={"status": TaskHistoryStatusEnum.RUNNING} - ) - context["running_tasks"] = response["items"] - context["stats"] = await tasks_api.get(f"/stats/{task.name}") - - context["executor_hosts"] = executor_hosts_ctx.with_host( - task_data["hostname"] - ).as_template_list() - context["executor_hosts_dict"] = executor_hosts_ctx.hosts - - try: - context["services"] = await fetch_all_dict_items( - lambda pagination: inventory_api.get( - "/services/", - params={ - "service_type": ServiceTypeEnum.MYSQL, - **pagination.model_dump(), - }, - ) - ) - except HTTPException: - context["services"] = [] - - context["alert_on_fail_default"] = task.alert_on_fail - context["alert_on_fail_available"] = bool(alert_settings.PROVIDERS) - context["chainable_tasks"] = await get_chainable_tasks( - tasks_api, task.owner, meta["target"], task.name - ) - - return templates.TemplateResponse( - request=request, - name="mysql_backups/backup/details.html.j2", - context=context, - ) - - -@router.post( - "/{task_name}", - dependencies=[IsAuthenticated, IsCsrfValidated, HasNoConflictedRunningTasks], - response_class=RedirectResponse, -) -async def mysql_backups_execute( - request: Request, - task: BackupsTask, - tasks_api: TaskAPI, - eta: Annotated[FutureDatetime | None, Form()] = None, - chain_task_names: Annotated[list[str] | None, Form()] = None, - chain_on_failure: Annotated[bool | None, Form()] = None, -) -> RedirectResponse: - """Execute backups task.""" - await tasks_api.post( - f"/execute/{task.name}", - json={ - "eta": eta, - "chain_task_names": chain_task_names, - "chain_on_failure": chain_on_failure, - }, - ) - task_path = request.url_for("mysql_backups_detail", task_name=task.name) - return RedirectResponse(task_path, status_code=status.HTTP_303_SEE_OTHER) - - -@router.post( - "/{task_name}/update", - dependencies=[IsAuthenticated, IsCsrfValidated], - response_class=RedirectResponse, -) -async def mysql_backups_update( - request: Request, - task_name: str, - updated_task: BackupGeneratedTask, - tasks_api: TaskAPI, -) -> RedirectResponse: - """Update backups task.""" - logger.debug("Updating backups task: %s", updated_task) - await tasks_api.put( - f"/{task_name}", - json=updated_task.model_dump(), - ) - return RedirectResponse( - request.url_for("mysql_backups_detail", task_name=updated_task.name), - status_code=status.HTTP_303_SEE_OTHER, - ) - - -@router.post( - "/{task_name}/delete", - dependencies=[IsAuthenticated, IsCsrfValidated], - response_class=RedirectResponse, -) -async def mysql_backups_delete( - task: BackupsTask, - tasks_api: TaskAPI, -) -> RedirectResponse: - """Delete backups task.""" - await tasks_api.delete(f"/{task.name}") - return RedirectResponse("/mysql_backups", status_code=status.HTTP_303_SEE_OTHER) diff --git a/app/sep/apps/report/__init__.py b/app/sep/apps/report/__init__.py index 1e1f68b08e..a204b568a5 100644 --- a/app/sep/apps/report/__init__.py +++ b/app/sep/apps/report/__init__.py @@ -14,6 +14,5 @@ # along with this program. If not, see . from app.sep.apps.report.app import app -from app.sep.apps.report.routes import router -__all__ = ["app", "router"] +__all__ = ["app"] diff --git a/app/sep/apps/report/app.py b/app/sep/apps/report/app.py index 9259d4127f..fd7302aa7c 100644 --- a/app/sep/apps/report/app.py +++ b/app/sep/apps/report/app.py @@ -16,8 +16,8 @@ """Wire the Health & Security Report plugin as a declarative ``BaseApp``. Register the bespoke report plugin through the registry's definition path -instead of the synthesized-legacy fallback, exposing the same JSON and Jinja -routers the registry imports today, and contribute the health-report generation +instead of the synthesized-legacy fallback, exposing the same JSON router +the registry imports today, and contribute the health-report generation and artifact-purge beat schedules via ``periodic_task_schedules``. """ @@ -26,7 +26,6 @@ from app.sep.apps.framework.base import AppPeriodicTask, BaseApp from app.sep.apps.nav_icons import NavIcon from app.sep.apps.report.api_routes import router as api_router -from app.sep.apps.report.routes import router as jinja_router from app.sep.config import sep_settings @@ -87,6 +86,5 @@ def _report_periodic_tasks() -> list[AppPeriodicTask]: react_route="/reports", nav_icon=NavIcon.BAR_CHART, api_router=api_router, - jinja_router=jinja_router, periodic_task_schedules=_report_periodic_tasks, ) diff --git a/static/img/percona-logo.png b/app/sep/apps/report/assets/percona-logo.png similarity index 100% rename from static/img/percona-logo.png rename to app/sep/apps/report/assets/percona-logo.png diff --git a/app/sep/apps/report/deps.py b/app/sep/apps/report/deps.py index aa1cc8bdce..2754b34240 100644 --- a/app/sep/apps/report/deps.py +++ b/app/sep/apps/report/deps.py @@ -15,62 +15,12 @@ """Define dependencies for the report plugin.""" -import logging -from typing import Annotated, Any - -from fastapi import Depends - -from app.core.exceptions import HTTPServiceUnavailableException -from app.sep.apps.report.models import REPORT_SECTION_LABELS -from app.sep.config import sep_settings - # ``get_pmm_api`` / ``PMMAPIDep`` / ``require_pmm_api`` / ``RequiredPMMAPIDep`` live # in ``app.sep.deps`` alongside the sibling Inventory / Tasks client deps; they are -# re-exported here for existing importers (routes, tests). +# re-exported here for existing importers (api_routes, celery, tests). from app.sep.deps import ( - DefaultContext, get_pmm_api, # noqa: F401 -- re-exported for existing importers - PMMAPIDep, + PMMAPIDep, # noqa: F401 -- re-exported for existing importers require_pmm_api, # noqa: F401 -- re-exported for existing importers RequiredPMMAPIDep, # noqa: F401 -- re-exported for existing importers ) - -logger = logging.getLogger(__name__) - - -async def require_upload_configured() -> None: - """Raise if report upload to ServiceNow is not configured. - - :raises HTTPServiceUnavailableException: If any required upload setting is - missing. - """ - if not sep_settings.HEALTH_REPORT.is_upload_configured: - raise HTTPServiceUnavailableException(detail="Report upload is not configured") - - -IsUploadConfigured = Depends(require_upload_configured) - - -async def get_report_index_context( - context: DefaultContext, - pmm_api: PMMAPIDep, -) -> dict[str, Any]: - """Assemble the template context for the report plugin index view. - - :param context: The default template context. - :type context: DefaultContext - :param pmm_api: The PMM API client or ``None`` (injected via ``PMMAPIDep``). - :type pmm_api: PMMRemoteAPI | None - :return: The updated context dictionary. - :rtype: dict[str, Any] - """ - context.update( - { - "pmm_configured": pmm_api is not None, - "sections": REPORT_SECTION_LABELS, - } - ) - return context - - -ReportIndexContext = Annotated[dict[str, Any], Depends(get_report_index_context)] diff --git a/app/sep/apps/report/routes.py b/app/sep/apps/report/routes.py deleted file mode 100644 index 4b41c4668a..0000000000 --- a/app/sep/apps/report/routes.py +++ /dev/null @@ -1,258 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define legacy Jinja routes for the report app. - -These Jinja2 routes are deprecated. The JSON API equivalents live under -``/api/apps/report/`` and the React UI consumes them via -``frontend/packages/apps/report``. Every response from this router carries the -RFC 8594 ``Deprecation: true`` header and emits a WARNING on hit; the routes -remain mounted only until the Jinja layer is removed. -""" - -import logging -from typing import Annotated - -from fastapi import APIRouter, Form, Query, Request, status -from fastapi.responses import HTMLResponse, JSONResponse, Response -from pydantic import ValidationError - -from app.sep.apps.framework.deprecation import DeprecatedJinja2Route -from app.sep.apps.report.celery import ( - render_report_pdf_job, - upload_report_snapshot_job, -) -from app.sep.config import sep_settings -from app.sep.deps import IsAuthenticated, IsCsrfValidated -from app.sep.middleware.csrf import CSRF_COOKIE_NAME - -from .deps import IsUploadConfigured, ReportIndexContext, RequiredPMMAPIDep -from .job_service import filter_report_sections, report_pdf_filename -from .models import ReportData -from .service import ( - generate_report, - SERVICE_NAMES, -) - -logger = logging.getLogger(__name__) -router = APIRouter(route_class=DeprecatedJinja2Route) -templates = sep_settings.TEMPLATES - - -@router.get( - "/", - dependencies=[IsAuthenticated], - response_class=HTMLResponse, - include_in_schema=False, -) -async def report_index(request: Request, context: ReportIndexContext) -> HTMLResponse: - """Render the report plugin landing page. - - :param request: The incoming HTTP request. - :type request: Request - :param context: Template context for the landing page. - :type context: ReportIndexContext - :return: Rendered report landing page. - :rtype: HTMLResponse - """ - return templates.TemplateResponse(request, "report/index.html.j2", context) - - -@router.post( - "/generate", - dependencies=[IsAuthenticated, IsCsrfValidated], - response_class=HTMLResponse, - include_in_schema=False, -) -async def report_generate( - request: Request, - pmm_api: RequiredPMMAPIDep, - context: ReportIndexContext, - since: Annotated[str, Form()] = "now-7d", - until: Annotated[str, Form()] = "now", - *, - full: Annotated[bool, Form()] = True, - refresh: Annotated[bool, Form()] = False, -) -> HTMLResponse: - """Generate a report and render the HTML result page. - - :param request: The incoming HTTP request. - :type request: Request - :param pmm_api: The PMM API client. - :type pmm_api: PMMRemoteAPI - :param context: Template context. - :type context: ReportIndexContext - :param since: Relative start of the report period. - :type since: str - :param until: Relative end of the report period. - :type until: str - :param full: Include all check results and full backup history. - :type full: bool - :param refresh: Force a refresh of advisor checks before fetching results. - :type refresh: bool - :return: Rendered HTML report. - :rtype: HTMLResponse - """ - report = await generate_report( - pmm_api, since=since, until=until, full=full, refresh=refresh - ) - context.update( - { - "report": report, - "service_names": SERVICE_NAMES, - "report_json": report.model_dump_json(), - "report_params": { - "since": since, - "until": until, - "full": full, - "refresh": refresh, - }, - "csrf_token": request.cookies.get(CSRF_COOKIE_NAME, ""), - "upload_disabled_reasons": sep_settings.HEALTH_REPORT.upload_disabled_reasons, - } - ) - return templates.TemplateResponse(request, "report/result.html.j2", context) - - -@router.get( - "/generate/json", - dependencies=[IsAuthenticated], - response_class=JSONResponse, - include_in_schema=False, -) -async def report_generate_json( - pmm_api: RequiredPMMAPIDep, - since: Annotated[str, Query()] = "now-7d", - until: Annotated[str, Query()] = "now", - *, - full: Annotated[bool, Query()] = True, - refresh: Annotated[bool, Query()] = False, - sections: Annotated[list[str] | None, Query()] = None, -) -> JSONResponse: - """Generate a report and return as JSON. - - :param pmm_api: The PMM API client. - :type pmm_api: PMMRemoteAPI - :param since: Relative start of the report period. - :type since: str - :param until: Relative end of the report period. - :type until: str - :param full: Include all check results and full backup history. - :type full: bool - :param refresh: Force a refresh of advisor checks before fetching results. - :type refresh: bool - :param sections: Optional list of sections to include. - :type sections: list[str] | None - :return: JSON response with the full report data. - :rtype: JSONResponse - """ - report = await generate_report( - pmm_api, - since=since, - until=until, - full=full, - refresh=refresh, - sections=filter_report_sections(sections), - ) - return JSONResponse(content=report.model_dump(mode="json")) - - -@router.post( - "/generate/pdf", - dependencies=[IsAuthenticated, IsCsrfValidated], - include_in_schema=False, -) -async def report_generate_pdf( - request: Request, - context: ReportIndexContext, - report_json: Annotated[str, Form()], -) -> Response: - """Enqueue PDF rendering and render an async job status page. - - :param request: The incoming HTTP request. - :type request: Request - :param context: Template context. - :type context: ReportIndexContext - :param report_json: Existing report JSON snapshot. - :type report_json: str - :return: Rendered job status page, or a validation error response. - :rtype: HTMLResponse | JSONResponse - """ - try: - report = ReportData.model_validate_json(report_json) - except ValidationError as exc: - return JSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - content={"detail": exc.errors()}, - ) - result = render_report_pdf_job.delay(report.model_dump(mode="json")) - context.update( - { - "job_id": result.id, - "job_title": "PDF generation started", - "job_description": f"Rendering {report_pdf_filename(report)}.", - "poll_url": str(request.url_for("report_pdf_job_api", job_id=result.id)), - "download_url": str( - request.url_for("report_download_pdf_api", job_id=result.id) - ), - "success_message": "PDF is ready.", - "back_url": str(request.url_for("report_index")), - } - ) - return templates.TemplateResponse(request, "report/job.html.j2", context) - - -@router.post( - "/upload", - dependencies=[IsAuthenticated, IsCsrfValidated, IsUploadConfigured], - response_class=HTMLResponse, - include_in_schema=False, -) -async def report_upload( - request: Request, - context: ReportIndexContext, - report_json: Annotated[str, Form()], -) -> Response: - """Enqueue PDF upload and render an async job status page. - - :param request: The incoming HTTP request. - :type request: Request - :param context: Template context. - :type context: ReportIndexContext - :param report_json: Existing report JSON snapshot. - :type report_json: str - :return: Rendered job status page, or a validation error response. - :rtype: HTMLResponse | JSONResponse - """ - try: - report = ReportData.model_validate_json(report_json) - except ValidationError as exc: - return JSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - content={"detail": exc.errors()}, - ) - result = upload_report_snapshot_job.delay(report.model_dump(mode="json")) - context.update( - { - "job_id": result.id, - "job_title": "ServiceNow upload started", - "job_description": f"Uploading {report_pdf_filename(report)}.", - "poll_url": str(request.url_for("report_upload_job_api", job_id=result.id)), - "download_url": None, - "success_message": "Report uploaded to ServiceNow.", - "back_url": str(request.url_for("report_index")), - } - ) - return templates.TemplateResponse(request, "report/job.html.j2", context) diff --git a/app/sep/apps/report/service.py b/app/sep/apps/report/service.py index 0e9b2a51ac..eef8124606 100644 --- a/app/sep/apps/report/service.py +++ b/app/sep/apps/report/service.py @@ -33,6 +33,7 @@ from pathlib import Path from typing import Any, TYPE_CHECKING +from jinja2 import Environment, FileSystemLoader from weasyprint import CSS, HTML from app.core.config import settings @@ -50,6 +51,7 @@ from app.sep.bundle_upload.seam import BundleSource from app.sep.clients.pmm import PMMRemoteAPI from app.sep.config import HealthReportSettings, sep_settings +from app.sep.utils.jinja import DEFAULT_FILTERS if TYPE_CHECKING: from collections.abc import Mapping @@ -913,9 +915,23 @@ async def generate_report( # PDF generation helpers -_LOGO_PNG_PATH = ( - Path(__file__).resolve().parents[4] / "static" / "img" / "percona-logo.png" -) +_ASSETS_DIR = Path(__file__).parent / "assets" +_TEMPLATES_DIR = Path(__file__).parent / "templates" +_LOGO_PNG_PATH = _ASSETS_DIR / "percona-logo.png" + + +@functools.lru_cache(maxsize=1) +def _get_pdf_environment() -> Environment: + """Return the Jinja environment that renders the report PDF template. + + The report PDF is the only server-rendered surface left, so it owns its + loader and filter set rather than sharing a SEP-wide environment. + + :return: An environment loading from this app's ``templates`` directory. + """ + env = Environment(loader=FileSystemLoader(_TEMPLATES_DIR), autoescape=True) + env.filters |= DEFAULT_FILTERS + return env @functools.lru_cache(maxsize=1) @@ -946,9 +962,7 @@ async def generate_pdf_report(report: ReportData) -> bytes: :return: The PDF file contents. :rtype: bytes """ - from app.sep.config import sep_settings - - template = sep_settings.JINJA_ENVIRONMENT.get_template("report/result_pdf.html.j2") + template = _get_pdf_environment().get_template("result_pdf.html.j2") html = template.render( report=report, service_names=SERVICE_NAMES, diff --git a/templates/report/result_pdf.html.j2 b/app/sep/apps/report/templates/result_pdf.html.j2 similarity index 100% rename from templates/report/result_pdf.html.j2 rename to app/sep/apps/report/templates/result_pdf.html.j2 diff --git a/app/sep/apps/snippets/app.py b/app/sep/apps/snippets/app.py index cfaf42f84f..4e038a1c3c 100644 --- a/app/sep/apps/snippets/app.py +++ b/app/sep/apps/snippets/app.py @@ -17,8 +17,7 @@ The registry discovers the exported ``app`` under the key ``snippets`` (derived from the module path), mounting its derived JSON router at -``/api/apps/snippets/`` while the existing Jinja UI keeps serving at -``/snippets/`` via the threaded ``jinja_router``. The JSON surface is the first +``/api/apps/snippets/``. The JSON surface is the first real adoption of the framework ``ScriptSource`` seam: listing, per-snippet form schema, execution history, and execute are derived from :data:`~app.sep.snippets.script_source.snippet_source`, while the @@ -37,14 +36,12 @@ """ from app.sep.apps.framework.apps import TaskExecutionApp -from app.sep.apps.framework.base import StaticMount from app.sep.apps.nav_icons import NavIcon from app.sep.apps.snippets.extra_routes import ( approval_router, artifact_router, maintenance_router, ) -from app.sep.apps.snippets.routes import router as jinja_router from app.sep.snippets.config import snippets_settings from app.sep.snippets.constants import ARTIFACT_TYPE_SNIPPET from app.sep.snippets.crud import SnippetManager @@ -80,13 +77,5 @@ def _snippets_capabilities_provider() -> SnippetsCapabilitiesResponse: list_query_spec=SnippetManager.list_query_spec, capabilities_provider=_snippets_capabilities_provider, extra_routes=(approval_router, maintenance_router, artifact_router), - jinja_router=jinja_router, artifact_base_dirs={ARTIFACT_TYPE_SNIPPET: lambda: snippets_settings.SNIPPETS_DIR}, - static_mounts=( - StaticMount( - path="/static/snippets", - directory=snippets_settings.SNIPPETS_DIR, - name="snippets_files", - ), - ), ) diff --git a/app/sep/apps/snippets/routes.py b/app/sep/apps/snippets/routes.py deleted file mode 100644 index ddf611c623..0000000000 --- a/app/sep/apps/snippets/routes.py +++ /dev/null @@ -1,311 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define routes for the Support Snippets plugin. - -These Jinja2 routes are deprecated. The JSON API equivalents live under -``/api/apps/snippets/`` and the React UI consumes them via -``frontend/packages/apps/snippets``. Every response from this router -carries the RFC 8594 ``Deprecation: true`` header and emits a WARNING on -hit; the routes remain mounted only until the Jinja layer is removed. The -React UI is at parity: chaining, scheduling and alerting are absent from -both surfaces, and the app declares them unsupported in its schema. - -Per-snippet HTML actions use fixed paths (``/detail``, ``/execute``, -``/approve``, ``/remove-approval``) and pass ``snippet_filename`` as a -query parameter so nested keys (e.g. ``diag/slow-query.sh``) never appear -as a path segment: Starlette rejects slashes in ``url_for`` path params. -""" - -import logging -from typing import Annotated - -from fastapi import APIRouter, Form, HTTPException, Request, status -from fastapi.responses import HTMLResponse, RedirectResponse -from sqlmodel import col - -from app.core.auth.exceptions import HTTPForbiddenException -from app.core.utils import utc_now -from app.sep.app_drain import track_app_task -from app.sep.apps.framework.deprecation import DeprecatedJinja2Route -from app.sep.config import sep_settings -from app.sep.deps import ( - AdminUser, - DefaultContext, - ExecutorHostsCtx, - IsAuthenticated, - IsCsrfValidated, - SessionDep, - TaskAPI, -) -from app.sep.middleware import messages -from app.sep.snippets.celery import update_snippets -from app.sep.snippets.config import snippets_settings -from app.sep.snippets.crud import SnippetManager -from app.sep.snippets.deps import ( - ApprovedSnippet, - check_snippet_batch_existence, - ExecutableSnippet, - SnippetBatchApproveForm, - SnippetExecutionRequestMeta, - UnapprovedSnippet, - ValidatedSnippet, -) -from app.sep.snippets.models import Snippet -from app.tasks.models import TaskHistoryStatusEnum - -logger = logging.getLogger(__name__) -router = APIRouter(route_class=DeprecatedJinja2Route) -templates = sep_settings.TEMPLATES - - -@router.get("/", dependencies=[IsAuthenticated], response_class=HTMLResponse) -async def snippets_index( - request: Request, - session: SessionDep, - context: DefaultContext, -) -> HTMLResponse: - """Homepage of snippets plugin.""" - context["snippets"] = await SnippetManager.list(session) - context["enable_snippets_refresh"] = snippets_settings.ENABLE_MANUAL_SYNC - return templates.TemplateResponse( - request=request, - name="snippets/index.html.j2", - context=context, - ) - - -def _get_snippet_success_redirect( - request: Request, user: AdminUser, snippet: Snippet, msg: str | None = None -) -> RedirectResponse: - messages.success(request, msg) - logger.info("%s by %s: %r", msg, user.username, snippet) - return RedirectResponse( - str( - request.url_for("snippets_detail").include_query_params( - snippet_filename=snippet.filename - ) - ), - status_code=status.HTTP_303_SEE_OTHER, - ) - - -@router.post("/approve", dependencies=[IsCsrfValidated]) -async def snippets_approve( - request: Request, user: AdminUser, snippet: UnapprovedSnippet, session: SessionDep -) -> RedirectResponse: - """Approve a snippet.""" - snippet.approve(f"Approved by {user.username}", str(user.id)) - await SnippetManager.save(session, snippet) - return _get_snippet_success_redirect(request, user, snippet, "Snippet approved") - - -@router.post("/remove-approval", dependencies=[IsCsrfValidated]) -async def snippets_remove_approval( - request: Request, user: AdminUser, snippet: ApprovedSnippet, session: SessionDep -) -> RedirectResponse: - """Remove the approval of a snippet.""" - snippet.remove_approval(f"Approval removed by {user.username}", str(user.id)) - await SnippetManager.save(session, snippet) - return _get_snippet_success_redirect( - request, user, snippet, "Snippet's approval removed" - ) - - -@router.post("/refresh", dependencies=[IsCsrfValidated]) -async def snippets_refresh( - request: Request, user: AdminUser, session: SessionDep -) -> RedirectResponse: - """Refresh the snippets.""" - if not snippets_settings.ENABLE_MANUAL_SYNC: - raise HTTPForbiddenException - async with track_app_task(session, "snippets"): - await update_snippets() - messages.success(request, "Snippets refreshed") - logger.info("Snippets refreshed by %s", user.username) - return RedirectResponse( - request.url_for("snippets_index"), status_code=status.HTTP_303_SEE_OTHER - ) - - -@router.post("/approve-batch", dependencies=[IsCsrfValidated]) -async def snippets_approve_batch( - request: Request, - user: AdminUser, - session: SessionDep, - body: Annotated[SnippetBatchApproveForm, Form()], -) -> RedirectResponse: - """Approve multiple snippets in a single atomic bulk operation. - - Reject the whole batch if any requested filename is unknown, its file is - missing on disk, or it is already approved. On success, flash a summary and - redirect back to the snippets index. - - :param request: The HTTP request object. - :type request: Request - :param user: The admin performing the action. - :type user: User - :param session: The active database session. - :type session: AsyncSession - :param body: The validated request body containing the filenames to approve. - :type body: SnippetBatchApproveForm - :return: A 303 redirect back to the snippets index. - :rtype: RedirectResponse - """ - index_redirect = RedirectResponse( - request.url_for("snippets_index"), status_code=status.HTTP_303_SEE_OTHER - ) - filenames = body.filenames - existence = await check_snippet_batch_existence(session, filenames) - if existence.missing_in_db: - messages.error( - request, f"Unknown snippet(s): {', '.join(existence.missing_in_db)}" - ) - return index_redirect - if existence.missing_on_disk: - messages.error( - request, - f"Snippet file(s) missing on disk: {', '.join(existence.missing_on_disk)}", - ) - return index_redirect - already_approved = sorted( - snippet.filename for snippet in existence.snippets if snippet.is_approved - ) - if already_approved: - messages.error(request, f"Already approved: {', '.join(already_approved)}") - return index_redirect - result = await SnippetManager.update_where( - session, - { - "approved_at": utc_now(), - "updated_by": str(user.id), - "reason": f"Batch approved by {user.username}", - }, - col(Snippet.filename).in_(filenames), - col(Snippet.approved_at).is_(None), - ) - updated_count = result.rowcount - logger.info( - "Batch-approved %d snippet(s) by %s: %s", - updated_count, - user.username, - filenames, - ) - if updated_count == 0: - messages.error( - request, - "No snippets were approved — their state changed before the batch " - "was processed. Please reload and retry.", - ) - return index_redirect - skipped = len(filenames) - updated_count - if skipped: - messages.success( - request, - f"{updated_count} snippet(s) approved " - f"({skipped} skipped — already approved by another admin)", - ) - return index_redirect - messages.success(request, f"{updated_count} snippet(s) approved") - return index_redirect - - -@router.post("/execute", dependencies=[IsAuthenticated]) -async def snippets_execute( - request: Request, - tasks_api: TaskAPI, - snippet: ExecutableSnippet, - execution_request_meta: SnippetExecutionRequestMeta, -) -> RedirectResponse: - """Execute a snippet.""" - logger.info( - "Executing [%s] snippet %r with args: %r", - execution_request_meta.interpreter, - snippet.filename, - execution_request_meta.args, - ) - logger.debug( - "Execution meta for snippet %r: %s", snippet.filename, execution_request_meta - ) - await tasks_api.post( - f"/execute/{snippet.execution_task_name}", - json={ - "meta": execution_request_meta.model_dump(by_alias=True, exclude_none=True) - }, - ) - return RedirectResponse( - str( - request.url_for("snippets_detail").include_query_params( - snippet_filename=snippet.filename - ) - ), - status_code=status.HTTP_303_SEE_OTHER, - ) - - -@router.get("/detail", dependencies=[IsAuthenticated], response_class=HTMLResponse) -async def snippets_detail( - snippet: ValidatedSnippet, - request: Request, - context: DefaultContext, - executor_hosts_ctx: ExecutorHostsCtx, - tasks_api: TaskAPI, -) -> HTMLResponse: - """Retrieve and display information about a snippet.""" - response = await tasks_api.get( - f"/{snippet.execution_task_name}/history/", - params={"snippet_filename": snippet.filename}, - ) - history_tasks = response["items"] - for history in history_tasks: - try: - history["available_files"] = await tasks_api.get( - f"/history/{history['id']}/files/" - ) - except HTTPException: - logger.debug( - "Could not fetch available files for task history %s", - history["id"], - exc_info=True, - ) - history["available_files"] = [] - response = await tasks_api.get( - f"/{snippet.execution_task_name}/history/", - params={ - "snippet_filename": snippet.filename, - "status": TaskHistoryStatusEnum.RUNNING, - }, - ) - context |= { - "snippet": snippet, - "executor_hosts": executor_hosts_ctx.as_form_hosts(), - "history_tasks": history_tasks, - "running_tasks": response["items"], - "snippet_execute_action": str( - request.url_for("snippets_execute").include_query_params( - snippet_filename=snippet.filename - ) - ), - } - context["snippet"] = snippet - try: - context["snippet_preview"] = await snippet.get_preview() - except UnicodeDecodeError: - logger.debug("Could not decode snippet code", exc_info=True) - return templates.TemplateResponse( - request=request, - name="snippets/details.html.j2", - context=context, - ) diff --git a/app/sep/apps/tasks/__init__.py b/app/sep/apps/tasks/__init__.py index 75770a7d18..5da629a171 100644 --- a/app/sep/apps/tasks/__init__.py +++ b/app/sep/apps/tasks/__init__.py @@ -14,6 +14,5 @@ # along with this program. If not, see . from app.sep.apps.tasks.app import app -from app.sep.apps.tasks.routes import router -__all__ = ["app", "router"] +__all__ = ["app"] diff --git a/app/sep/apps/tasks/app.py b/app/sep/apps/tasks/app.py index 24f1eddd71..c7df0bf6ae 100644 --- a/app/sep/apps/tasks/app.py +++ b/app/sep/apps/tasks/app.py @@ -24,7 +24,6 @@ from app.sep.apps.framework.base import BaseApp from app.sep.apps.nav_icons import NavIcon from app.sep.apps.tasks.api_routes import router as api_router -from app.sep.apps.tasks.routes import router as jinja_router from app.sep.apps.tasks.schema import TASKS_PLUGIN_SCHEMA app = BaseApp( @@ -36,7 +35,6 @@ react_route="/tasks", nav_icon=NavIcon.ASSIGNMENT, api_router=api_router, - jinja_router=jinja_router, schema=TASKS_PLUGIN_SCHEMA, uses_task_data=True, ) diff --git a/app/sep/apps/tasks/routes.py b/app/sep/apps/tasks/routes.py deleted file mode 100644 index 9b047b3e8d..0000000000 --- a/app/sep/apps/tasks/routes.py +++ /dev/null @@ -1,94 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define routes for the Tasks App.""" - -import logging - -from fastapi import APIRouter, Request -from fastapi.responses import HTMLResponse - -from app.sep.apps.framework.deprecation import DeprecatedJinja2Route -from app.sep.apps.tasks.deps import TaskDep -from app.sep.config import sep_settings -from app.sep.deps import ( - AVAILABLE_TIMEZONES, - DefaultContext, - ExecutorHostsCtx, - IsAuthenticated, - TaskAPI, -) -from app.tasks.models import TaskHistoryStatusEnum - -logger = logging.getLogger(__name__) - -router = APIRouter(route_class=DeprecatedJinja2Route) - -templates = sep_settings.TEMPLATES - - -@router.get("/", dependencies=[IsAuthenticated], response_class=HTMLResponse) -async def tasks_list( - request: Request, - context: DefaultContext, - tasks_api: TaskAPI, -) -> HTMLResponse: - """Render the Tasks app homepage with the current and running task lists. - - :param request: The incoming request used to render the template response. - :param context: The default template context for the page. - :param tasks_api: Client used to fetch the task and history lists. - :return: The rendered Tasks list page. - """ - response = await tasks_api.get("/") - context["tasks"] = response["items"] - response = await tasks_api.get( - "/history/", params={"status": TaskHistoryStatusEnum.RUNNING} - ) - context["running_tasks"] = response["items"] - logger.debug("context: %s", context["running_tasks"]) - return templates.TemplateResponse( - request=request, - name="tasks/list.html.j2", - context=context, - ) - - -@router.get("/{task_name}", dependencies=[IsAuthenticated], response_class=HTMLResponse) -async def tasks_detail( - task: TaskDep, - request: Request, - context: DefaultContext, - tasks_api: TaskAPI, - executor_hosts_ctx: ExecutorHostsCtx, -) -> HTMLResponse: - """Retrieve task.""" - context["task"] = task - context["tasks"] = [task] - if not task.is_template: - response = await tasks_api.get(f"/{task.name}/history/") - context["history"] = response["items"] - response = await tasks_api.get( - f"/{task.name}/history/", params={"status": TaskHistoryStatusEnum.RUNNING} - ) - context["running_tasks"] = response["items"] - context["task_data"] = task.data - context["executor_hosts"] = executor_hosts_ctx.as_template_list() - context["AVAILABLE_TIMEZONES"] = AVAILABLE_TIMEZONES - return templates.TemplateResponse( - request=request, - name="tasks/view.html.j2", - context=context, - ) diff --git a/app/sep/apps/topology/api_routes.py b/app/sep/apps/topology/api_routes.py index 8058ff7959..b5e7a31552 100644 --- a/app/sep/apps/topology/api_routes.py +++ b/app/sep/apps/topology/api_routes.py @@ -64,7 +64,6 @@ from app.sep.deps import ( ApiCurrentUser, InventoryAPI, - IsCsrfValidated, TaskAPI, ) from app.tasks.models import TaskHistoryStatusEnum @@ -149,12 +148,7 @@ def _select_topology_targets( return list(available_hosts.keys())[:shards] -@router.post( - "/collect", - dependencies=[IsCsrfValidated], - response_model=TopologyCollectResponse, - status_code=status.HTTP_202_ACCEPTED, -) +@router.post("/collect", status_code=status.HTTP_202_ACCEPTED) async def topology_collect( body: TopologyCollectWrite, inventory_api: InventoryAPI, @@ -309,7 +303,7 @@ def _is_terminal_task_status(status_value: Any) -> bool: return False -@router.get("/result", response_model=TopologyResultResponse) +@router.get("/result") async def topology_result( tasks_api: TaskAPI, current_user: ApiCurrentUser, diff --git a/app/sep/apps/topology/app.py b/app/sep/apps/topology/app.py index 08493f535a..b52fc54f9e 100644 --- a/app/sep/apps/topology/app.py +++ b/app/sep/apps/topology/app.py @@ -17,8 +17,8 @@ Topology renders a bespoke React Flow graph, so it ships a hand-written ``api_router`` and a custom UI rather than the framework's schema-driven -surface. It carries no Jinja UI. Enablement is purely a function of app -registration (``SEP.APPS``); there is no separate feature flag. +surface. Enablement is purely a function of app registration (``SEP.APPS``); +there is no separate feature flag. """ from app.sep.apps.framework.base import BaseApp diff --git a/app/sep/config.py b/app/sep/config.py index 09c1f5da17..d523683617 100644 --- a/app/sep/config.py +++ b/app/sep/config.py @@ -16,21 +16,17 @@ """Define SEP settings.""" import logging -from datetime import datetime, timedelta -from functools import cached_property +from datetime import timedelta from pathlib import Path from string import Template from typing import Annotated, Any, ClassVar, Literal, Self from urllib.parse import urlparse from annotated_types import Gt -from fastapi.templating import Jinja2Templates -from jinja2 import Environment, FileSystemLoader from pydantic import ( AliasChoices, AliasGenerator, BaseModel, - computed_field, ConfigDict, Field, field_validator, @@ -76,8 +72,6 @@ ) from app.sep.apps.nav_icons import NavIcon from app.sep.bundle_upload.plan import DeliveryPlan -from app.sep.middleware import messages -from app.sep.utils.jinja import DEFAULT_FILTERS, syntax_highlight_css logger = logging.getLogger(__name__) @@ -311,16 +305,6 @@ def _validate_celery_module_exists(self) -> Self: return self raise ValueError(f"No module named {self.celery_module_path}") - @computed_field - @property - def router_path(self) -> str: - """Return the plugin's router path. - - :return: The router path derived from the module name. - :rtype: str - """ - return f"{self.module_name}.router" - class SessionOptions(BaseModel): """Configuration options for a SEP session. @@ -705,16 +689,10 @@ class SEPSettings(BaseYamlAppSettings): :cvar SETTINGS_PREFIXES: The prefixes for SEP-related settings in the configuration file. Set to ["SEP"]. :param UVICORN_PORT: The port number used by the Uvicorn server. Defaults to 8000. - :param SESSION: Session configuration options for the legacy ``authToken`` - cookie used by the Jinja UI. :param SESSION_REFRESH: Session configuration options for the SPA ``refreshToken`` cookie. The cookie is ``HttpOnly`` and scoped to ``/api/oauth`` by default. When overriding ``PATH`` via YAML or env vars, the value must start with ``/``. - :param TEMPLATES_DIR: The directory containing template files. Defaults to - ``Path("templates")``. - :param STATIC_DIR: The directory containing static files. Defaults to - ``Path("static")``. :param ALERT_DEFINITIONS_DIR: Path to the directory containing YAML alert definition files. When ``None``, the bundled ``alert_definitions/`` directory inside the alerts plugin is used. @@ -769,7 +747,6 @@ class SEPSettings(BaseYamlAppSettings): SETTINGS_PREFIXES: ClassVar[list[str]] = ["SEP"] UVICORN_PORT: int = 8000 - SESSION: SessionOptions = nested_overridable_field(SessionOptions(), advanced=True) SESSION_REFRESH: SessionOptions = nested_overridable_field( SessionOptions( COOKIE_NAME="refreshToken", @@ -777,8 +754,6 @@ class SEPSettings(BaseYamlAppSettings): ), advanced=True, ) - TEMPLATES_DIR: RelativeDirectoryPathField = Path("templates") - STATIC_DIR: RelativeDirectoryPathField = Path("static") ALERT_DEFINITIONS_DIR: RelativeDirectoryPathField | None = None INVENTORY_ENDPOINT: CredentialHttpUrl = hot_field(..., advanced=True) TASKS_ENDPOINT: CredentialHttpUrl = hot_field(..., advanced=True) @@ -883,45 +858,6 @@ def settings_customise_sources( _warn_legacy_apps_key() return sources - @computed_field - @cached_property - def JINJA_ENVIRONMENT(self) -> Environment: - """Return a Jinja2 Environment object for templates. - - This property creates, caches, and returns a - :class:`jinja2.environment.Environment` object configured with the - ``jinja2.ext.do`` extension, the ``syntax_highlight`` filter, and the - ``syntax_highlight_css`` utility function as global. - - :return: The Environment configured for Jinja2. - :rtype: Environment - """ - env = Environment( - loader=FileSystemLoader(sep_settings.TEMPLATES_DIR), - autoescape=True, - extensions=["jinja2.ext.do", "jinja2.ext.loopcontrols"], - ) - env.filters |= DEFAULT_FILTERS - env.globals["now"] = datetime.now - env.globals["syntax_highlight_css"] = syntax_highlight_css - env.globals["get_messages"] = messages.get_messages - return env - - @computed_field - @cached_property - def TEMPLATES(self) -> Jinja2Templates: - """Return a Jinja2Templates object for template rendering. - - This property creates, caches, and returns a ``Jinja2Templates`` object configured - with the ``TEMPLATES_DIR`` directory. - - :return: The Jinja2 templates object for rendering templates. - :rtype: Jinja2Templates - """ - return Jinja2Templates( - env=self.JINJA_ENVIRONMENT, - ) - @field_validator("FOOTER_TEMPLATE", mode="before") @classmethod def coerce_footer_template(cls, v: Any) -> Any: diff --git a/app/sep/connectivity.py b/app/sep/connectivity.py index c915a9f789..ce2a6310ef 100644 --- a/app/sep/connectivity.py +++ b/app/sep/connectivity.py @@ -15,27 +15,21 @@ """Provide connectivity check helpers and an async LRU cache for task creation. -These helpers serve the **form (Jinja) flow** — they emit flash messages via -:func:`app.sep.middleware.messages.warning` and depend on a Starlette -:class:`~starlette.requests.Request`. The **JSON API flow** uses the parallel -helpers in :mod:`app.sep.apps.framework.connectivity`, which return a -``ConnectivityWarning | None`` object instead of mutating request state. +These are the shared primitives — the memoized probe and the latest-result +snapshot. The request-facing wrapper that turns a probe into a +``ConnectivityWarning | None`` lives in +:mod:`app.sep.apps.framework.connectivity`. """ import logging from collections import OrderedDict -from collections.abc import Iterable from typing import Any, cast, NamedTuple from aiohttp import ClientError from async_lru import alru_cache from fastapi import HTTPException -from pydantic import ValidationError -from starlette.requests import Request from app.core.requests import RemoteAPI -from app.core.utils import run_pydantic_type_validator -from app.sep.middleware import messages logger = logging.getLogger(__name__) @@ -50,8 +44,6 @@ CONNECTIVITY_META_HOST_KEY = "_connectivity_host" CONNECTIVITY_META_PORT_KEY = "_connectivity_port" CONNECTIVITY_META_SERVICE_TYPE_KEY = "_connectivity_service_type" -CONNECTIVITY_TARGET_KEY = "_connectivity_target" -CONNECTIVITY_WARNING_KEY = "_connectivity_warning" _LATEST_RESULTS: OrderedDict[tuple[str, str], bool] = OrderedDict() @@ -71,40 +63,15 @@ class ConnectivityResult(NamedTuple): task_history_id: int | None -async def get_check_connectivity_flag(request: Request) -> bool: - """Return whether the task creation form requested a connectivity check. - - Read ``check_connectivity`` from already-parsed form data. Starlette caches - :meth:`starlette.requests.Request.form` on the request, so this coexists - safely with CSRF validation and other dependencies that parse the body. - - Delegate boolean coercion to Pydantic's built-in validator, which accepts - HTML checkbox submissions (``on``) and common programmatic values - (``true``/``false``, ``yes``/``no``, ``1``/``0``). Unparseable or missing - values fall back to ``False``. - - :param request: The incoming HTTP request. - :return: ``True`` when the checkbox or equivalent field is set. - """ - form = await request.form() - try: - return run_pydantic_type_validator(bool, form.get("check_connectivity")) - except ValidationError: - return False - - def _record_latest_result(target: str, service_type: str, *, success: bool) -> None: """Record the most recent connectivity outcome for sync UI annotations. Maintain a bounded LRU snapshot of the latest connectivity outcome per - ``(target, service_type)`` pair so the synchronous - ``annotate_tasks_with_connectivity`` can flag tasks without consulting - ``alru_cache`` (which exposes no public sync peek). The snapshot has no - TTL of its own; ``alru_cache`` remains the authoritative TTL store and - the snapshot is best-effort, refreshed each time - ``check_and_warn_connectivity`` (form path) or - ``app.sep.apps.framework.connectivity.record_connectivity_warning`` - (JSON API path) runs. + ``(target, service_type)`` pair so a synchronous caller can read it without + consulting ``alru_cache`` (which exposes no public sync peek). The snapshot + has no TTL of its own; ``alru_cache`` remains the authoritative TTL store + and the snapshot is best-effort, refreshed each time + ``app.sep.apps.framework.connectivity.record_connectivity_warning`` runs. :param target: The Nomad node name. :param service_type: The lowercase service type (e.g. ``mysql``). @@ -205,109 +172,3 @@ async def _fetch_connectivity_result( return ConnectivityResult( success=False, error="Could not reach the Tasks API", task_history_id=None ) - - -async def check_and_warn_connectivity( - request: Request, - tasks_api: RemoteAPI, - *, - target: str, - host: str, - port: int, - service_type: str, -) -> None: - """Run a connectivity check and flash a warning on failure. - - Delegate the actual Tasks API call to ``_fetch_connectivity_result`` so - its result is memoized via ``alru_cache``. Subsequent calls with the - same ``(tasks_api, target, host, port, service_type)`` tuple short- - circuit until ``CACHE_TTL`` seconds elapse. - - :param request: The HTTP request (for flash messages). - :param tasks_api: Authenticated Tasks API client. - :param target: The Nomad node name. - :param host: The database host address. - :param port: The database port. - :param service_type: The lowercase service type (e.g. ``mysql``). - """ - success, error, task_history_id = await _fetch_connectivity_result( - tasks_api, target, host, port, service_type - ) - _record_latest_result(target, service_type, success=success) - - if not success: - pointer = ( - f" (see task #{task_history_id} logs)" - if task_history_id is not None - else "" - ) - messages.warning( - request, - f"Connectivity warning for {host}:{port} ({service_type}) " - f"on executor {target}: {error or 'check failed'}{pointer}", - ) - - -async def maybe_check_connectivity( - request: Request, - tasks_api: RemoteAPI, - meta: dict[str, Any], - *, - check_connectivity: bool = True, -) -> None: - """Run ``check_and_warn_connectivity`` when ``meta`` carries connectivity data. - - Extract ``target``, ``_connectivity_host``, ``_connectivity_port``, and - ``_connectivity_service_type`` from ``meta`` and delegate to - ``check_and_warn_connectivity``. Return silently when ``check_connectivity`` - is ``False`` or when any of those keys are missing or falsy, which lets - task-creation routes invoke this helper unconditionally without inspecting - the task payload themselves. - - :param request: The HTTP request (for flash messages). - :param tasks_api: Authenticated Tasks API client. - :param meta: The ``task.data["meta"]`` mapping from the created task. - :param check_connectivity: If ``False``, skip both the Tasks API call and - the ``_LATEST_RESULTS`` cache write. Honors the per-task opt-out from - the SEP task creation forms. - """ - if not check_connectivity: - return - target = meta.get("target") - host = meta.get(CONNECTIVITY_META_HOST_KEY) - port = meta.get(CONNECTIVITY_META_PORT_KEY) - service_type = meta.get(CONNECTIVITY_META_SERVICE_TYPE_KEY) - if target and host and port and service_type: - await check_and_warn_connectivity( - request, - tasks_api, - target=target, - host=host, - port=port, - service_type=service_type, - ) - - -def annotate_tasks_with_connectivity(tasks: Iterable[dict[str, Any]]) -> None: - """Add ``_connectivity_warning`` flag to tasks based on the latest results. - - Support two task dict formats: - - - Raw task dicts with ``data.meta`` containing ``target`` and - ``_connectivity_service_type``. - - Enriched ``task_info`` dicts with top-level ``_connectivity_target`` and - ``_connectivity_service_type`` keys. - - :param tasks: The iterable of task dictionaries to annotate in-place. - """ - for task in tasks: - target = task.get(CONNECTIVITY_TARGET_KEY) - service_type = task.get(CONNECTIVITY_META_SERVICE_TYPE_KEY) - if target is None or service_type is None: - meta = task.get("data", {}).get("meta", {}) - target = meta.get("target") - service_type = meta.get(CONNECTIVITY_META_SERVICE_TYPE_KEY) - if target and service_type: - success = _LATEST_RESULTS.get((target, service_type)) - if success is not None: - task[CONNECTIVITY_WARNING_KEY] = not success diff --git a/app/sep/deps.py b/app/sep/deps.py index 38fafc8426..3d77178870 100644 --- a/app/sep/deps.py +++ b/app/sep/deps.py @@ -15,15 +15,12 @@ """Define SEP dependencies.""" -import hmac import logging from collections.abc import AsyncGenerator, Awaitable, Callable, Sequence from typing import Annotated, Any -from zoneinfo import available_timezones import aiohttp -from fastapi import Depends, HTTPException, Request, status -from itsdangerous import BadSignature +from fastapi import Depends, HTTPException, Request from pydantic import ValidationError from sqlalchemy.exc import SQLAlchemyError from sqlmodel.ext.asyncio.session import AsyncSession @@ -31,7 +28,6 @@ from app import __summary__, __version__ from app.api.deps import get_current_user as get_current_user_api from app.api.deps import oauth2_scheme -from app.core.alerts.config import alert_settings from app.core.auth import config as auth_config from app.core.auth.base import BaseAuthProvider from app.core.auth.exceptions import HTTPForbiddenException, HTTPUnauthorizedException @@ -39,30 +35,18 @@ from app.core.auth.utils import get_user_model from app.core.config import settings from app.core.exceptions import ( - HTTPBadRequestException, HTTPConflictException, HTTPNotFoundException, - HTTPRedirectException, HTTPServiceUnavailableException, ) -from app.core.log import set_log_context from app.core.pagination import fetch_all_dict_items from app.core.requests import RemoteAPI -from app.core.security import crypto_timestamp_serializer from app.core.utils.fields import URL from app.inventory.config import inventory_settings -from app.inventory.models import ServiceTypeEnum from app.sep.clients.pmm import PMMRemoteAPI from app.sep.config import sep_settings -from app.sep.connectivity import ( - annotate_tasks_with_connectivity, - CONNECTIVITY_META_SERVICE_TYPE_KEY, - CONNECTIVITY_TARGET_KEY, - get_check_connectivity_flag, -) from app.sep.crud import AppStateManager from app.sep.db import get_async_session_maker -from app.sep.exceptions import LoginRedirectException from app.sep.inventory import ( CreatedEntity, CreatedNode, @@ -71,12 +55,6 @@ CreatedTable, ENTITY_MAPPING, ) -from app.sep.middleware import messages -from app.sep.middleware.csrf import ( - CSRF_COOKIE_NAME, - CSRF_FORM_FIELD, - request_has_bearer_authorization, -) from app.sep.models import SyncInventoryEntityTypeEnum from app.tasks.config import tasks_settings from app.tasks.models import ( @@ -87,7 +65,6 @@ logger = logging.getLogger(__name__) User = get_user_model() -AVAILABLE_TIMEZONES = sorted(available_timezones()) def get_base_url(request: Request) -> URL: @@ -109,74 +86,15 @@ def get_base_url(request: Request) -> URL: BaseURL = Annotated[URL, Depends(get_base_url)] -def get_access_token_from_cookie( - request: Request, -) -> str: - """Retrieve and verify the access token from a session cookie. - - Extracts the signed access token from the request cookies, verifies it, and - returns the unsigned token. If verification fails, raises a login - redirect exception. - - :param request: The HTTP request containing the session cookie. - :type request: Request - :return: The verified and unsigned access token. - :rtype: str - :raises LoginRedirectException: If the token is invalid or cannot be - verified due to a `BadSignature`. - """ - signed_access_token = request.cookies.get(sep_settings.SESSION.COOKIE_NAME, "") - try: - return crypto_timestamp_serializer.loads( - signed_access_token, - max_age=sep_settings.SESSION.MAX_AGE.total_seconds(), - ) - except BadSignature: - logger.debug("Failed to unsign token") - raise LoginRedirectException(request) from None - - -AccessTokenCookie = Annotated[str, Depends(get_access_token_from_cookie)] - - -async def get_current_user_from_cookie(request: Request) -> User: - """Return the authenticated user from the signed session cookie. - - Loads and verifies the session cookie, decodes the JWT into a user, and - rejects inactive accounts with a login redirect (legacy Jinja2 behavior). - - :param request: The incoming HTTP request. - :type request: Request - :return: The authenticated user. - :rtype: User - :raises LoginRedirectException: If the cookie or JWT is invalid or the user - is inactive. - """ - token = get_access_token_from_cookie(request) - try: - user = await User.from_jwt(token) - except (BadSignature, ValidationError) as exc: - logger.debug("Failed to authenticate user: %s", exc, exc_info=True) - raise LoginRedirectException(request) from None - if not user.is_active: - logger.debug("User %s is not active", user.username) - # TODO: Message on inactive # noqa: TD002, TD003 - raise LoginRedirectException(request) - set_log_context(user=user.username) - return user - - def is_bearer_authenticated(request: Request) -> bool: """Return whether the request carries an ``Authorization: Bearer`` header. Inspects only the ``Authorization`` header prefix — the token itself is not - validated. Intended as a routing signal to pick between Bearer and cookie - authentication, and to render API-style error responses for SPA clients. + validated. Used to reject a credential-less request before ``oauth2_scheme`` + can raise a bare Starlette error, and to gate mutating methods. :param request: The incoming HTTP request. - :type request: Request :return: ``True`` when the header starts with ``Bearer ``, ``False`` otherwise. - :rtype: bool """ return request.headers.get("authorization", "").lower().startswith("bearer ") @@ -184,67 +102,26 @@ def is_bearer_authenticated(request: Request) -> bool: async def get_current_user( request: Request, ) -> User: - """Return the authenticated user from a Bearer token or session cookie. + """Return the authenticated user from the ``Authorization: Bearer`` header. - The ``Authorization: Bearer`` header is tried first (React SPA) and, when - present, failures from :func:`app.api.deps.get_current_user` are raised as - HTTP API errors (401/403) rather than converted into a login redirect — - including the case of a malformed/empty Bearer token. When the header is - absent, authentication falls back to the signed session cookie (legacy - Jinja2). + The header is checked explicitly before delegating to ``oauth2_scheme``: + that scheme carries ``auto_error=True`` and would raise a bare Starlette + ``HTTPException`` for a header-less request, bypassing SEP's project + exceptions. :param request: The incoming HTTP request. - :type request: Request :return: The authenticated user. - :rtype: User - :raises HTTPUnauthorizedException: If a Bearer token is present but invalid. - :raises HTTPForbiddenException: If the user resolved from Bearer is inactive. - :raises LoginRedirectException: If cookie-based auth fails or the cookie user - is inactive. + :raises HTTPUnauthorizedException: If no Bearer token is present, or the + token is invalid. + :raises HTTPForbiddenException: If the resolved user is inactive. """ - if is_bearer_authenticated(request): - bearer_token = await oauth2_scheme(request) - return await get_current_user_api(bearer_token) - - return await get_current_user_from_cookie(request) - - -IsAuthenticated = Depends(get_current_user) -CurrentUser = Annotated[User, IsAuthenticated] - - -async def get_api_authenticated_user(request: Request) -> User: - """Return the authenticated user for API surfaces. - - Wrap :func:`get_current_user` so cookie-based API callers receive an - ``HTTPUnauthorizedException`` (401) instead of the - ``LoginRedirectException`` (303) used by Jinja pages. The - ``set-cookie`` header that ``LoginRedirectException`` uses to clear a - stale session cookie is preserved on the 401 response so the invalid - cookie does not linger on the client. Bearer-token failures from - :func:`get_current_user` (``HTTPUnauthorizedException`` / - ``HTTPForbiddenException``) propagate unchanged. - - :param request: The incoming HTTP request. - :type request: Request - :return: The authenticated user. - :rtype: User - :raises HTTPUnauthorizedException: If cookie-based authentication fails - (converted from :class:`LoginRedirectException`), or if a Bearer - token is missing or invalid. - :raises HTTPForbiddenException: If the user resolved from the Bearer - token is inactive. - """ - try: - return await get_current_user(request) - except HTTPRedirectException as exc: - unauthorized = HTTPUnauthorizedException() - if "set-cookie" in exc.headers: - unauthorized.headers = {"set-cookie": exc.headers["set-cookie"]} - raise unauthorized from None + if not is_bearer_authenticated(request): + raise HTTPUnauthorizedException + bearer_token = await oauth2_scheme(request) + return await get_current_user_api(bearer_token) -IsApiAuthenticated = Depends(get_api_authenticated_user) +IsApiAuthenticated = Depends(get_current_user) ApiCurrentUser = Annotated[User, IsApiAuthenticated] @@ -254,21 +131,20 @@ async def get_api_authenticated_user(request: Request) -> User: async def require_bearer_for_unsafe_methods(request: Request) -> None: """Require a Bearer Authorization header on mutating HTTP methods. - Cookie-authenticated mutations would bypass CSRF protection because - :func:`validate_csrf` operates on form-body fields and JSON requests - carry no form body. Browsers never attach an ``Authorization`` header - automatically, so requiring a Bearer token on mutating routes blocks - cross-site JSON POSTs from a malicious origin. ``GET``, ``HEAD`` and - ``OPTIONS`` pass through (cookie-authenticated SSR reads and CORS - preflights are unaffected). ``POST``, ``PUT``, ``PATCH`` and ``DELETE`` - require ``Authorization: Bearer ...``; cookie-authenticated cross-site - JSON mutations are rejected with ``401`` before any business logic runs. + Browsers never attach an ``Authorization`` header automatically, so + requiring a Bearer token on mutating routes blocks cross-site JSON POSTs + from a malicious origin. ``GET``, ``HEAD`` and ``OPTIONS`` pass through + (reads and CORS preflights are unaffected). ``POST``, ``PUT``, ``PATCH`` + and ``DELETE`` require ``Authorization: Bearer ...``. - Intended to be attached at router level to ``/api/apps/*`` so every - plugin's JSON mutation routes inherit the guard uniformly. + This is a backstop rather than the primary control: :func:`get_current_user` + already rejects a header-less request on every method, so wherever + ``IsApiAuthenticated`` is also attached this dependency cannot be the one + that rejects. It is kept at router level so that a route reachable without + that alias, or a future non-Bearer authentication path, still inherits the + guard uniformly. :param request: The incoming HTTP request. - :type request: Request :raises HTTPUnauthorizedException: When the method is unsafe and the request lacks an ``Authorization: Bearer`` header. """ @@ -281,32 +157,9 @@ async def require_bearer_for_unsafe_methods(request: Request) -> None: RequireBearerForUnsafeMethods = Depends(require_bearer_for_unsafe_methods) -async def get_current_admin(current_user: CurrentUser) -> User: - """Return the authenticated admin. - - :param current_user: The current logged-in user. - :type current_user: CurrentUser - :return: The authenticated admin user. - :rtype: User - :raises HTTPForbiddenException: If the user is not an admin. - """ - if not current_user.is_admin: - raise HTTPForbiddenException - return current_user - - -IsAdminDep = Depends(get_current_admin) -AdminUser = Annotated[User, IsAdminDep] - - async def get_api_authenticated_admin(api_user: ApiCurrentUser) -> User: """Return the authenticated API admin user. - Mirror :func:`get_current_admin` but ride on the API auth path - (:func:`get_api_authenticated_user`) so failures surface as 401 / 403 - JSON responses rather than the cookie-based 303 redirect to the login - page. - :param api_user: The current API-authenticated user. :type api_user: ApiCurrentUser :return: The authenticated admin user. @@ -322,23 +175,6 @@ async def get_api_authenticated_admin(api_user: ApiCurrentUser) -> User: ApiAdminUser = Annotated[User, IsApiAdmin] -async def redirect_if_user_is_authenticated(request: Request) -> None: - """Redirect authenticated users to homepage. - - This dependency function checks if the session cookie is set in the request and, - if so, redirects the authenticated user to the homepage. - - :param request: The HTTP request object. - :type request: Request - :raises HTTPRedirectException: If the session cookie is set. - """ - if request.cookies.get(sep_settings.SESSION.COOKIE_NAME): - raise HTTPRedirectException("/", status_code=status.HTTP_303_SEE_OTHER) - - -IsNotAuthenticated = Depends(redirect_if_user_is_authenticated) - - def _ambient_session_provider() -> BaseAuthProvider | None: """Return the active auth provider when ambient-session auth is available. @@ -404,58 +240,6 @@ async def resolve_ambient_exchange_token( return None -async def validate_csrf(request: Request) -> None: - """Validate the CSRF token submitted in the request form data. - - Requests with ``Authorization: Bearer ...`` that do *not* also carry a - session cookie skip CSRF validation; Bearer tokens are not sent - automatically by browsers, so CSRF protection is not required for that - path (authentication is enforced separately). When a session cookie is - also present the request is treated as cookie-authenticated and CSRF - validation is enforced normally, regardless of any Bearer header. - - For authenticated requests (session cookie present), verify the HMAC - signature using the session cookie as salt. For unauthenticated requests - (login page), verify using a double-submit cookie comparison plus - signature verification. - - :param request: The HTTP request object. - :type request: Request - :raises HTTPBadRequestException: If the CSRF token is missing from the - form data. - :raises HTTPForbiddenException: If the CSRF token fails validation. - """ - session_cookie = request.cookies.get(sep_settings.SESSION.COOKIE_NAME) - if request_has_bearer_authorization(request) and not session_cookie: - return - - form_data = await request.form() - form_token = str(form_data.get(CSRF_FORM_FIELD, "")) - if not form_token: - raise HTTPBadRequestException(detail="Missing CSRF token.") - - max_age = int(sep_settings.SESSION.MAX_AGE.total_seconds()) - - if session_cookie: - try: - crypto_timestamp_serializer.loads( - form_token, salt=session_cookie, max_age=max_age - ) - except BadSignature: - raise HTTPForbiddenException(detail="CSRF validation failed.") from None - else: - csrf_cookie = request.cookies.get(CSRF_COOKIE_NAME) - if not csrf_cookie or not hmac.compare_digest(form_token, csrf_cookie): - raise HTTPForbiddenException(detail="CSRF validation failed.") - try: - crypto_timestamp_serializer.loads(form_token, max_age=max_age) - except BadSignature: - raise HTTPForbiddenException(detail="CSRF validation failed.") from None - - -IsCsrfValidated = Depends(validate_csrf) - - async def get_username_mapping() -> dict[str, str]: """Create a mapping from user ID to username using the active auth provider. @@ -535,14 +319,12 @@ def require_app_enabled(app_key: str) -> Callable[[AsyncSession], Awaitable[None A failed app-state read degrades to ENABLED rather than failing the request: a configured plugin stays reachable when the DB is unreachable (or the - ``appstate`` table is missing), mirroring :func:`get_default_context`. This - is fail-open by design -- the gate is an operator convenience, not a security - boundary, so a transient DB fault must not 500 every guarded route. + ``appstate`` table is missing). This is fail-open by design: the gate is an + operator convenience, not a security boundary, so a transient DB fault must + not 500 every guarded route. :param app_key: The plugin module key to gate on. - :type app_key: str :return: A FastAPI dependency coroutine that raises 503 when disabled. - :rtype: Callable[[AsyncSession], Awaitable[None]] """ async def _gate(session: SessionDep) -> None: @@ -603,9 +385,7 @@ def render_footer_text() -> str: Read :attr:`sep_settings.FOOTER_TEMPLATE` per call (it is a hot, materializer-backed setting) so a live ``SEP__FOOTER_TEMPLATE`` override is - reflected without restarting the application. This is the single source of - truth shared by the Jinja default context and the JSON app-info endpoint so - the two frontends cannot drift. + reflected without restarting the application. :return: The rendered footer text (application summary and version by default). """ @@ -614,75 +394,12 @@ def render_footer_text() -> str: ) -async def get_default_context( - request: Request, - user: CurrentUser, - base_uri: BaseURL, - session: SessionDep, -) -> dict[str, Any]: - """Return the default context for templates. - - The sidebar ``plugins`` list is filtered by *effective* app state via - :meth:`AppRegistry.resolve_effective_enabled`: protected apps always pass - through; every other app is shown only when its own - :class:`app.sep.models.AppState` row is ``ENABLED`` (a missing row is treated - as enabled) **and** every app it declares in ``requires_apps`` is itself - effectively enabled. A child app owns no row, so it resolves through its - parent via :attr:`~app.sep.apps.framework.base.BaseApp.state_key`. This - shares the one resolver used by the mount gate and the JSON app listing. - - :param request: The HTTP request object. - :param user: The authenticated user. - :param base_uri: The base URI of the application. - :param session: The database session used to read app state. - :return: The default context. - """ - try: - states = await AppStateManager.all_lifecycle_states(session) - except SQLAlchemyError: - # Error pages rebuild this context from a fresh session; keep them - # renderable when the DB is down. - logger.warning( - "Could not read app state; rendering all apps in the sidebar.", - exc_info=True, - ) - states = {} - # Deferred: the framework package __init__ imports back into this module, - # so a top-level import here would cycle. - from app.sep.apps.framework.registry import get_app_registry - - registry = get_app_registry() - memo: dict[str, bool] = {} - plugins = [ - app - for app in registry - if registry.resolve_effective_enabled(app.key, states, memo) - ] - return { - "user": user, - "base_uri": base_uri, - "plugins": plugins, - "sync_refresh_time": sep_settings.SYNC_REFRESH_TIME, - "csrf_token": getattr(request.state, "csrf_token", ""), - "pmm_url": settings.PMM.frontend, - "footer_text": render_footer_text(), - "user_id_to_username": await get_username_mapping(), - } - - -DefaultContext = Annotated[dict[str, Any], Depends(get_default_context)] - -CheckConnectivityFlag = Annotated[bool, Depends(get_check_connectivity_flag)] - - async def get_inventory_client(request: Request) -> RemoteAPI: - """Construct a `RemoteAPI` instance for interacting with the Inventory API. + """Construct a ``RemoteAPI`` instance for interacting with the Inventory API. :param request: The HTTP request object. - :type request: Request - :return: An instance of `RemoteAPI` configured for the Inventory service, including - the endpoint, API key, and SSL settings. - :rtype: RemoteAPI + :return: An instance of ``RemoteAPI`` configured for the Inventory service, + including the endpoint, API key, and SSL settings. """ return getattr( request.app.state, "inventory_api", None @@ -701,18 +418,15 @@ async def get_inventory_client(request: Request) -> RemoteAPI: # TODO(yan): Proper SDK # SEP-130 async def get_inventory_api( - inventory_client: InventoryClient, user: CurrentUser + inventory_client: InventoryClient, user: ApiCurrentUser ) -> AsyncGenerator[RemoteAPI]: """Construct a `RemoteAPI` instance for interacting with the Inventory API. :param inventory_client: The Inventory API client. - :type inventory_client: RemoteAPI :param user: The current authenticated user, from which the access token is extracted. - :type user: User - :return: An instance of `RemoteAPI` configured for the Inventory service, including - the endpoint, API key, and SSL settings. - :rtype: RemoteAPI + :return: An instance of ``RemoteAPI`` configured for the Inventory service, + including the endpoint, API key, and SSL settings. """ with inventory_client.auth(user.access_token) as authenticated_api: yield authenticated_api @@ -745,18 +459,15 @@ async def get_tasks_client(request: Request) -> RemoteAPI: async def get_tasks_api( - tasks_client: TasksClient, user: CurrentUser + tasks_client: TasksClient, user: ApiCurrentUser ) -> AsyncGenerator[RemoteAPI]: """Construct a `RemoteAPI` instance for interacting with the Tasks API. :param tasks_client: The Tasks API client. - :type tasks_client: RemoteAPI :param user: The current authenticated user, from which the access token is extracted. - :type user: User :return: An instance of `RemoteAPI` configured for the Tasks service, including the endpoint, API key, and SSL settings. - :rtype: RemoteAPI """ with tasks_client.auth(user.access_token) as authenticated_api: yield authenticated_api @@ -1045,202 +756,27 @@ async def get_executor_hosts_context( ExecutorHostsCtx = Annotated[ExecutorHostsContext, Depends(get_executor_hosts_context)] -async def get_executor_hosts(request: Request, tasks_api: TaskAPI) -> dict[str, str]: +async def get_executor_hosts(tasks_api: TaskAPI) -> dict[str, str]: """Retrieve executor hosts from the Tasks API. - :param request: The HTTP request object. - :type request: Request + An upstream failure degrades to an empty mapping rather than propagating, so + a Tasks-service outage leaves the consuming surfaces renderable. + :param tasks_api: The API client used to interact with the tasks service. - :type tasks_api: TaskAPI :return: A dictionary of executor hosts. - :rtype: dict[str, str] """ try: return await tasks_api.get("/hosts/") - except HTTPException as exc: - messages.error(request, exc.detail) + except HTTPException: + logger.warning( + "Could not read executor hosts from the Tasks API.", exc_info=True + ) return {} ExecutorHosts = Annotated[dict[str, str], Depends(get_executor_hosts)] -async def get_tasks_context( - inventory_api: InventoryAPI, - tasks_api: TaskAPI, - get_task_info_func: Callable[[dict[str, Any]], dict[str, Any]], - executor_hosts_ctx: ExecutorHostsCtx, - default_context: DefaultContext | None = None, - owner: str | None = None, - *, - service_type: ServiceTypeEnum, - alert_on_fail_default: bool = False, -) -> dict[str, Any]: - """Assemble the template context for task-dependent plugins. - - This function retrieves inventory services (scoped by ``service_type``), - tasks (filtered by ``owner``), and their histories from the Inventory and - Tasks APIs. It organizes tasks based on their status and integrates them - into the provided context. - - :param inventory_api: The API client used to interact with the inventory service. - :param tasks_api: The API client used to interact with the tasks service. - :param get_task_info_func: A callable that receives a task and returns - the processed task information. - :param executor_hosts_ctx: The enriched executor hosts context with display names. - :param default_context: The base context dictionary to update. If None (default), - initializes an empty dictionary. - :param owner: The owner filter for retrieving tasks. Defaults to ``None``. - :param service_type: The inventory service type whose services scope the - ``/services/`` fetch. - :param alert_on_fail_default: Default value for the alert on failure setting. - :return: The assembled context dictionary containing tasks and services - information, including ``connectivity_check_default`` sourced from - ``sep_settings.CONNECTIVITY_CHECK_DEFAULT``. - """ - services = await fetch_all_dict_items( - lambda pagination: inventory_api.get( - "/services/", - params={"service_type": service_type, **pagination.model_dump()}, - ) - ) - - tasks = [] - history_tasks = [] - scheduled_tasks = [] - running_tasks = [] - tasks_response = await tasks_api.get("/", params={"owner": owner}) - for task in tasks_response["items"]: - task_info = { - "name": task["name"], - "id": task["id"], - "created_by": task.get("created_by"), - "last_updated_by": task.get("last_updated_by"), - } - task_info |= get_task_info_func(task) - meta = task.get("data", {}).get("meta", {}) - if CONNECTIVITY_META_SERVICE_TYPE_KEY in meta: - task_info[CONNECTIVITY_TARGET_KEY] = meta.get("target", "") - task_info[CONNECTIVITY_META_SERVICE_TYPE_KEY] = meta[ - CONNECTIVITY_META_SERVICE_TYPE_KEY - ] - tasks.append(task_info) - response = await tasks_api.get(f"/{task['name']}/history/") - for hist in response["items"]: - match TaskHistoryStatusEnum(hist["status"]): - case TaskHistoryStatusEnum.PENDING: - scheduled_tasks.append(hist) - case TaskHistoryStatusEnum.RUNNING: - running_tasks.append(hist) - case _: - history_tasks.append(hist) - annotate_tasks_with_connectivity(tasks) - periodic_tasks = await tasks_api.get("/periodic/", params={"owner": owner}) - - alert_on_fail_available = bool(alert_settings.PROVIDERS) - context = default_context or {} - context.update( - { - "executor_hosts": executor_hosts_ctx.as_template_list(), - "services": services, - "tasks": tasks, - "pending_tasks": scheduled_tasks, - "running_tasks": running_tasks, - "history_tasks": history_tasks, - "periodic_tasks": periodic_tasks, - "chainable_tasks": tasks, - "AVAILABLE_TIMEZONES": AVAILABLE_TIMEZONES, - "alert_on_fail_default": alert_on_fail_available and alert_on_fail_default, - "alert_on_fail_available": alert_on_fail_available, - "connectivity_check_default": sep_settings.CONNECTIVITY_CHECK_DEFAULT, - } - ) - return context - - -async def get_chainable_tasks( - tasks_api: RemoteAPI, - owner: str, - target: str, - exclude_task_name: str, -) -> list[dict[str, Any]]: - """Fetch tasks that can be chained after a given task. - - Return tasks matching the same owner and target, excluding the current task. - - :param tasks_api: The Tasks API client. - :type tasks_api: RemoteAPI - :param owner: The task owner to filter by. - :type owner: str - :param target: The execution target to filter by. - :type target: str - :param exclude_task_name: The current task name to exclude from results. - :type exclude_task_name: str - :return: A list of chainable task dicts. - :rtype: list[dict[str, Any]] - """ - response = await tasks_api.get("/", params={"owner": owner, "target": target}) - return [t for t in response["items"] if t["name"] != exclude_task_name] - - -async def get_tasks_index_context( - inventory_api: InventoryAPI, - tasks_api: TaskAPI, - default_context: DefaultContext, - executor_hosts_ctx: ExecutorHostsCtx, -) -> dict[str, Any]: - """Assemble the context for the Homepage. - - Retrieve services and associated tasks, organizing them based on their - execution status. Integrate this information into the default context for - rendering in templates. - - :param inventory_api: The Inventory API client for fetching service and schema data. - :type inventory_api: InventoryAPI - :param tasks_api: The TaskAPI client for fetching task data. - :type tasks_api: TaskAPI - :param default_context: The default context to be updated with Alters-specific information. - :type default_context: DefaultContext - :param executor_hosts_ctx: The enriched executor hosts context with display names. - :type executor_hosts_ctx: ExecutorHostsCtx - :return: An updated context dictionary containing tasks' data. - :rtype: dict[str, Any] - """ - response = await tasks_api.get( - "/history/", params={"status": TaskHistoryStatusEnum.RUNNING} - ) - running_tasks = response["items"] - response = await tasks_api.get( - "/history/", params={"status": TaskHistoryStatusEnum.PENDING} - ) - scheduled_tasks = response["items"] - periodic_tasks = await tasks_api.get("/periodic/", params={"enabled": "True"}) - response = await tasks_api.get("/") - task_owner_mapping = {task["name"]: task["owner"] for task in response["items"]} - for periodic_task in periodic_tasks: - task_name = periodic_task.get("task") - periodic_task["owner"] = task_owner_mapping.get(task_name) - inventories = await inventory_api.get("/summary/") - # Derive from the already-filtered plugin list so a disabled Task Manager - # does not leave the homepage rendering links into 503-returning routes. - plugins = default_context.get("plugins", []) - is_task_manager_enabled = any( - p.name == "Task Manager" and p.sidebar for p in plugins - ) - context = default_context - context.update( - { - **inventories, - "running_tasks": running_tasks, - "pending_tasks": scheduled_tasks, - "periodic_tasks": periodic_tasks, - "executor_hosts": executor_hosts_ctx.as_host_metrics(), - "is_task_manager_enabled": is_task_manager_enabled, - }, - ) - return context - - # TODO(yan): Put get_task in a proper TasksAPI SDK class # SEP-130 async def get_task_by_name( diff --git a/app/sep/exceptions.py b/app/sep/exceptions.py deleted file mode 100644 index 63cb3f5041..0000000000 --- a/app/sep/exceptions.py +++ /dev/null @@ -1,48 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define reusable exceptions for the SEP app.""" - -from http.cookies import SimpleCookie - -from fastapi import Request, status - -from app.core.exceptions import HTTPRedirectException -from app.sep.config import sep_settings - - -class LoginRedirectException(HTTPRedirectException): - """Define exception raised for login redirects. - - This exception clears the previous session cookie, if any, and redirects the user to - the login page. - - :param request: The HTTP request object, used to build the login redirect URL. - :type request: Request - """ - - def __init__(self, request: Request) -> None: - super().__init__( - f"{request.url_for('login').path}?next={request.url.path}", - status_code=status.HTTP_303_SEE_OTHER, - ) - cookie = SimpleCookie() - cookie[sep_settings.SESSION.COOKIE_NAME] = "" - cookie[sep_settings.SESSION.COOKIE_NAME]["httponly"] = True - cookie[sep_settings.SESSION.COOKIE_NAME]["secure"] = sep_settings.SESSION.SECURE - cookie[sep_settings.SESSION.COOKIE_NAME]["samesite"] = ( - sep_settings.SESSION.SAMESITE - ) - self.headers["set-cookie"] = cookie.output(header="").strip() diff --git a/app/sep/inventory.py b/app/sep/inventory.py index 2d9a7d0c32..8c1bd84f59 100644 --- a/app/sep/inventory.py +++ b/app/sep/inventory.py @@ -424,6 +424,14 @@ def __repr__(self) -> str: return f"Table(name={self.name!r}, schema_id={self.schema_id})" +# ``Service``/``Schema`` reference each other's children before those classes +# exist, so Pydantic defers their build. Resolve it here, where every name is in +# scope, rather than leaving it to whichever consumer happens to build a model +# first -- a subclass declared in another module (``SystemFactsService``) cannot +# resolve ``Schema`` from its own namespace. +for _model in (Node, CreatedNode, Service, CreatedService, Schema, CreatedSchema): + _model.model_rebuild() + CreatedEntity = CreatedNode | CreatedService | CreatedSchema | CreatedTable ENTITY_MAPPING = { diff --git a/app/sep/main.py b/app/sep/main.py index 0d30f0a793..db47747b0d 100644 --- a/app/sep/main.py +++ b/app/sep/main.py @@ -19,17 +19,13 @@ from collections.abc import AsyncGenerator, Callable, Mapping from contextlib import asynccontextmanager from copy import deepcopy -from traceback import format_exception -from typing import Annotated, Any -from urllib.parse import urlsplit +from typing import Any -from fastapi import Depends, FastAPI, HTTPException, Query, Request, status +from fastapi import FastAPI, HTTPException, Request, status from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError -from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response -from fastapi.security import OAuth2PasswordRequestForm -from pydantic import HttpUrl, ValidationError -from starlette.staticfiles import StaticFiles +from fastapi.responses import JSONResponse +from pydantic import HttpUrl from app import __summary__, __version__ from app.api.main import api_router as top_level_api_router @@ -41,14 +37,11 @@ from app.core.exceptions import HTTPBadGatewayException, HTTPServiceUnavailableException from app.core.health import build_health_router from app.core.requests import RemoteAPI -from app.core.security import crypto_timestamp_serializer from app.core.settings_override.lifecycle import ( RefreshCallback, settings_override_refresher, ) from app.core.settings_override.models import SettingClassEnum -from app.core.utils import run_pydantic_type_validator -from app.core.utils.fields import URIPath from app.inventory.config import inventory_settings from app.sep.api.router import api_router from app.sep.apps.framework.registry import ( @@ -57,41 +50,16 @@ from app.sep.config import sep_settings from app.sep.db import get_async_session_maker from app.sep.db.seed import get_system_periodic_tasks, init_sep_db -from app.sep.deps import ( - AccessTokenCookie, - get_base_url, - get_current_user, - get_default_context, - get_tasks_index_context, - is_bearer_authenticated, - IsAuthenticated, - IsCsrfValidated, - IsNotAuthenticated, - PROTECTED_APP_KEYS, - require_app_enabled, - resolve_ambient_session_token, -) -from app.sep.exceptions import LoginRedirectException -from app.sep.middleware import CSRFMiddleware, messages -from app.sep.middleware.csrf import CSRF_COOKIE_NAME -from app.sep.middleware.messages.config import messages_settings from app.sep.settings_override import ( build_sep_override_proxies, invalidate_pmm_clients, ) from app.sep.snippets.celery import sync_snippets from app.sep.snippets.config import snippets_settings -from app.sep.utils.static import AuthenticatedStaticFiles from app.tasks.config import tasks_settings logger = logging.getLogger(__name__) -JSON_API_PATH_PREFIXES: tuple[str, ...] = ( - "/api/sep/", - "/api/admin/", - "/api/apps/", -) - def warn_if_ambient_sso_inert() -> None: """Emit a warning when ambient SSO is enabled but the active provider can't honor it. @@ -221,10 +189,9 @@ async def _reseed_system_periodic_tasks(_: Mapping[str, object]) -> None: async def sep_overrides_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Wire the SEP-side settings override refresher into a lifespan. - Force-resolves ``messages_settings`` (fail-fast validation), then starts the - background refresher for the duration of the wrapped block over the proxy - map :func:`build_sep_override_proxies` composes -- the shared set every SEP - process refreshes, so no wiring drifts from another's. + Start the background refresher for the duration of the wrapped block over + the proxy map :func:`build_sep_override_proxies` composes, the shared set + every SEP process refreshes, so no wiring drifts from another's. Endpoint and PMM rebind callbacks are built here -- where ``app`` is available -- so both run modes wire them. @@ -243,11 +210,6 @@ async def sep_overrides_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: callbacks against ``app.state``. :return: None """ - # Force-resolve ``messages_settings`` so the proxy's underlying Pydantic - # instance is constructed (and validated) before any lifespan side - # effects (DB init, snippet sync enqueue) can fire. Mirrors the previous - # eager ``MessagesSettings()`` fail-fast behavior at import time. - messages_settings._resolve() # noqa: SLF001 callbacks = { ( SettingClassEnum.SEP_SETTINGS, @@ -349,27 +311,11 @@ async def sep_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: version=__version__, description=( f"{__summary__}\n\n" - "Browser-oriented SEP routes (HTML, redirects, proxies, streams). " + "Browser-oriented SEP routes (proxies, streams, downloads). " "JSON REST APIs for inventory and tasks live on the mounted sub-apps." ), ) -sep_app.add_middleware(CSRFMiddleware) -sep_app.add_middleware(messages.MessagesMiddleware) - - -jinja_ui_mounted = False -for app in get_app_registry(): - if app.jinja_router is None: - continue - plugin_deps = ( - [] - if app.state_key in PROTECTED_APP_KEYS - else [Depends(require_app_enabled(app.key))] - ) - sep_app.include_router( - app.jinja_router, prefix=app.uri_path, dependencies=plugin_deps - ) - jinja_ui_mounted = True + if any(app.uses_task_data for app in get_app_registry()): from app.sep.routes.download_files import router as download_files_router @@ -380,15 +326,6 @@ async def sep_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: sep_app.include_router(download_files_router, prefix="/files") sep_app.include_router(execution_events_router, prefix="/execution-events") -if jinja_ui_mounted: - from app.sep.routes.inventory_ajax import router as inventory_ajax_router - from app.sep.routes.periodic_tasks import router as periodic_tasks_router - from app.sep.routes.stop_task import router as stop_task_router - - sep_app.include_router(inventory_ajax_router, prefix="/inventory-api") - sep_app.include_router(stop_task_router, prefix="/stop-task") - sep_app.include_router(periodic_tasks_router, prefix="/periodic") - if any(app.artifact_base_dirs for app in get_app_registry()): from app.sep.routes.artifacts import router as artifacts_router @@ -397,112 +334,66 @@ async def sep_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: sep_app.include_router(api_router) sep_app.include_router(top_level_api_router, include_in_schema=False) -for app in get_app_registry(): - for static_mount in app.static_mounts: - sep_app.mount( - static_mount.path, - AuthenticatedStaticFiles(directory=static_mount.directory), - name=static_mount.name, - ) -sep_app.mount("/static", StaticFiles(directory=sep_settings.STATIC_DIR), name="static") - User = get_user_model() -templates = sep_settings.TEMPLATES @sep_app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR) async def internal_error_handler( - request: Request, + request: Request, # noqa: ARG001 exc: BaseException, -) -> HTMLResponse | JSONResponse | RedirectResponse: - """Load custom error page.""" - logger.exception("Unhandled exception:", exc_info=exc) - if request.url.path.startswith(JSON_API_PATH_PREFIXES): - return JSONResponse( - {"detail": "Internal Server Error"}, - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) +) -> JSONResponse: + """Return a JSON error response for unhandled server errors. - base_url = get_base_url(request) - try: - user = await get_current_user(request) - except LoginRedirectException as redirect_exc: - return RedirectResponse( - redirect_exc.location, - status_code=redirect_exc.status_code, - headers=redirect_exc.headers, - ) - messages.error( - request, - "Internal Server Error. Please contact the administrators for help.", - sticky=True, - ) - async with get_async_session_maker()() as session: - default_context = await get_default_context(request, user, base_url, session) - return templates.TemplateResponse( - request=request, + :param request: The incoming request. + :param exc: The unhandled exception. + :return: A JSON response carrying a generic 500 detail. + """ + logger.exception("Unhandled exception:", exc_info=exc) + return JSONResponse( + {"detail": "Internal Server Error"}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - name="error.html.j2", - context={ - "exception": "".join(format_exception(exc, limit=-1, chain=False)), - **default_context, - }, ) @sep_app.exception_handler(status.HTTP_404_NOT_FOUND) async def custom_404_handler( - request: Request, + request: Request, # noqa: ARG001 exc: BaseException, -) -> Response: - """Load custom 404 page.""" - if request.url.path.startswith(JSON_API_PATH_PREFIXES): - detail = getattr(exc, "detail", "Not Found") - headers = getattr(exc, "headers", None) - return JSONResponse( - {"detail": detail}, - status_code=status.HTTP_404_NOT_FOUND, - headers=headers, - ) +) -> JSONResponse: + """Return a JSON error response for unmatched routes. - base_url = get_base_url(request) - try: - user = await get_current_user(request) - except LoginRedirectException as redirect_exc: - return RedirectResponse( - redirect_exc.location, - status_code=redirect_exc.status_code, - headers=redirect_exc.headers, - ) - async with get_async_session_maker()() as session: - default_context = await get_default_context(request, user, base_url, session) - return templates.TemplateResponse( - request=request, + ``exc`` is a bare :class:`BaseException` because the handler is registered on + a status code rather than an exception class, so ``detail`` and ``headers`` + are read defensively. + + :param request: The incoming request. + :param exc: The exception that produced the 404. + :return: A JSON response with the error detail. + """ + return JSONResponse( + {"detail": getattr(exc, "detail", "Not Found")}, status_code=status.HTTP_404_NOT_FOUND, - name="404.html.j2", - context={ - "exception": exc, - **default_context, - }, + headers=getattr(exc, "headers", None), ) @sep_app.exception_handler(BaseAuthProviderException) async def auth_provider_exception_handler( - request: Request, exc: BaseAuthProviderException -) -> RedirectResponse: - """Handle exceptions raised by auth providers.""" + request: Request, # noqa: ARG001 + exc: BaseAuthProviderException, +) -> JSONResponse: + """Return a JSON error response for auth-provider failures. + + :param request: The incoming request. + :param exc: The auth-provider exception to handle. + :return: A JSON response with the error detail and status code. + """ logger.exception("Error connecting to auth provider:", exc_info=exc) - messages.error(request, exc.detail, sticky=True) - next_path = request.query_params.get("next", request.url.path) - redirect_location = request.url_for("login").path - if next_path and next_path != redirect_location: - redirect_location += f"?next={next_path}" - response = RedirectResponse( - redirect_location, status_code=status.HTTP_303_SEE_OTHER + return JSONResponse( + {"detail": exc.detail}, + status_code=exc.status_code, + headers=exc.headers, ) - response.delete_cookie(sep_settings.SESSION.COOKIE_NAME) - return response @sep_app.exception_handler(HTTPServiceUnavailableException) @@ -528,184 +419,37 @@ async def json_exception_handler( @sep_app.exception_handler(HTTPException) -async def default_exception_handler(request: Request, exc: HTTPException) -> Response: - """Define default exception handler.""" - if request.url.path.startswith(JSON_API_PATH_PREFIXES) or is_bearer_authenticated( - request - ): - return JSONResponse( - {"detail": exc.detail}, - status_code=exc.status_code, - headers=exc.headers, - ) +async def default_exception_handler( + request: Request, # noqa: ARG001 + exc: HTTPException, +) -> JSONResponse: + """Return a JSON error response for any otherwise-unhandled HTTP exception. - error_detail = exc.detail - messages.error(request, str(error_detail)) - return RedirectResponse( - request.headers.get("referer", "/"), status_code=status.HTTP_303_SEE_OTHER + :param request: The incoming request. + :param exc: The HTTP exception to handle. + :return: A JSON response with the error detail and status code. + """ + return JSONResponse( + {"detail": exc.detail}, + status_code=exc.status_code, + headers=exc.headers, ) @sep_app.exception_handler(RequestValidationError) async def request_validation_exception_handler( - request: Request, exc: RequestValidationError -) -> Response: - """Surface form-body validation errors as flash messages, not raw JSON. - - FastAPI's default :class:`RequestValidationError` handler returns a - ``application/json`` 422 with a serialized error list, which the browser - renders as a raw JSON blob via its built-in JSON viewer. That's fine for - JSON API consumers but a poor UX for users submitting HTML forms — they - end up staring at structured error JSON instead of returning to the form - with an inline message. - - For non-API paths and session-authenticated requests we convert each - validator failure into a flash message via :func:`messages.from_validation_error` - and redirect back to the referer (the form page). ``none_required`` is - excluded because every ``T | EmptyStrToNone``-shaped field produces a - redundant ``none_required`` alongside the real validator failure when a - non-empty value fails the ``T`` arm's constraint. - """ - if request.url.path.startswith(JSON_API_PATH_PREFIXES) or is_bearer_authenticated( - request - ): - return JSONResponse( - {"detail": jsonable_encoder(exc.errors())}, - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - ) - - messages.from_validation_error( - request, - exc, - "Validation error", - exclude_types=("none_required",), - ) - return RedirectResponse( - request.headers.get("referer", "/"), status_code=status.HTTP_303_SEE_OTHER - ) - - -def _safe_next_path(next_path: str) -> str: - r"""Validate a ``next`` redirect target, collapsing unsafe values to ``/``. - - Validate ``next_path`` as a same-origin ``URIPath`` so the password login and - the ambient auto-login reject open-redirect targets identically. ``URIPath`` - alone still admits scheme-relative (``//host``) and backslash (``/\host``) - targets that a browser follows off-origin, so also reject any value that a - browser would resolve to a foreign host. + request: Request, # noqa: ARG001 + exc: RequestValidationError, +) -> JSONResponse: + """Return the serialized validator failures as a JSON 422. - :param next_path: The raw ``next`` query value. - :return: The validated relative path, or ``/`` when ``next_path`` is not a - safe same-origin path. - """ - try: - validated = run_pydantic_type_validator(URIPath, next_path) - except ValidationError: - return "/" - if validated.startswith(("//", "/\\")) or urlsplit(validated).netloc: - return "/" - return validated - - -@sep_app.get( - "/login", - dependencies=[IsNotAuthenticated], - include_in_schema=False, - response_model=None, -) -async def login_form( - request: Request, next_path: Annotated[str, Query(alias="next")] = "/" -) -> HTMLResponse | RedirectResponse: - """Serve the login form, or auto-login from an ambient Grafana session. - - Attempt ambient Grafana SSO before rendering: on a valid ambient session, - redirect to the sanitized ``next`` target with the SEP session cookie set; - otherwise render the login form unchanged. - - :param request: The incoming request, carrying any ambient Grafana session - cookie. - :param next_path: The post-login redirect target (the ``next`` query param). - :return: A redirect carrying the session cookie on ambient auto-login, else - the rendered login form. + :param request: The incoming request. + :param exc: The request-validation error to handle. + :return: A JSON response carrying the encoded error list. """ - oauth_token = await resolve_ambient_session_token(request) - if oauth_token is not None: - response = RedirectResponse( - _safe_next_path(next_path), status_code=status.HTTP_303_SEE_OTHER - ) - response.set_cookie( - **sep_settings.SESSION.model_dump(by_alias=True), - value=crypto_timestamp_serializer.dumps(oauth_token.access_token), - httponly=True, - ) - response.delete_cookie(CSRF_COOKIE_NAME) - return response - return templates.TemplateResponse( - request=request, - name="login.html.j2", - context={ - "csrf_token": request.state.csrf_token, - "next_path": next_path, - }, - ) - - -@sep_app.post( - "/login", - dependencies=[IsNotAuthenticated, IsCsrfValidated], - include_in_schema=False, -) -async def login( - form_data: Annotated[OAuth2PasswordRequestForm, Depends()], - next_path: Annotated[str, Query(alias="next")] = "/", -) -> RedirectResponse: - """Authenticate user from their username and password.""" - # TODO(yan): Prevent malicious account lockout - # SEP-277 - oauth_token = await User.get_oauth_token( - username=form_data.username, password=form_data.password - ) - if not settings.ALLOW_CONCURRENT_SESSIONS: - await User.invalidate_tokens_for_user( - form_data.username, exclude_tokens=[oauth_token.access_token] - ) - response = RedirectResponse( - _safe_next_path(next_path), status_code=status.HTTP_303_SEE_OTHER - ) - response.set_cookie( - **sep_settings.SESSION.model_dump(by_alias=True), - value=crypto_timestamp_serializer.dumps(oauth_token.access_token), - httponly=True, - ) - response.delete_cookie(CSRF_COOKIE_NAME) - return response - - -@sep_app.post( - "/logout", dependencies=[IsAuthenticated, IsCsrfValidated], include_in_schema=False -) -async def logout(access_token: AccessTokenCookie) -> RedirectResponse: - """Logout route.""" - response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) - response.delete_cookie(sep_settings.SESSION.COOKIE_NAME) - response.delete_cookie(CSRF_COOKIE_NAME) - try: - await User.invalidate_oauth_token(access_token) - except (KeyError, ValidationError): - logger.debug("Failed to invalidate OAuth token", exc_info=True) - return response - - -@sep_app.get("/", response_class=HTMLResponse, include_in_schema=False) -async def read_root( - request: Request, - context: Annotated[dict[str, Any], Depends(get_tasks_index_context)], -) -> HTMLResponse: - """Homepage route.""" - return templates.TemplateResponse( - request=request, - name="homepage.html.j2", - context=context, + return JSONResponse( + {"detail": jsonable_encoder(exc.errors())}, + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, ) diff --git a/app/sep/middleware/__init__.py b/app/sep/middleware/__init__.py deleted file mode 100644 index 6bc8f663d5..0000000000 --- a/app/sep/middleware/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -from app.sep.middleware.csrf import CSRFMiddleware -from app.sep.middleware.messages import MessagesMiddleware diff --git a/app/sep/middleware/csrf.py b/app/sep/middleware/csrf.py deleted file mode 100644 index c1514a811e..0000000000 --- a/app/sep/middleware/csrf.py +++ /dev/null @@ -1,165 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define middleware to handle CSRF protection. - -Implement the OWASP Signed Double-Submit Cookie pattern using -``itsdangerous.URLSafeTimedSerializer``. When an ``authToken`` session cookie -is present the CSRF token is HMAC-bound to it (``salt=session_cookie``); -otherwise a plain signed token is used for unauthenticated pages such as the -login form. -""" - -from __future__ import annotations - -import secrets -from typing import TYPE_CHECKING - -from fastapi.staticfiles import StaticFiles -from itsdangerous import BadSignature -from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint - -from app.core.security import crypto_timestamp_serializer - -if TYPE_CHECKING: - from starlette.requests import Request - from starlette.responses import Response - from starlette.types import ASGIApp - - from app.sep.config import SEPSettings - -CSRF_COOKIE_NAME = "_csrf" -CSRF_FORM_FIELD = "csrf-token" - -__all__ = [ - "CSRF_COOKIE_NAME", - "CSRF_FORM_FIELD", - "CSRFMiddleware", - "request_has_bearer_authorization", -] - - -def request_has_bearer_authorization(request: Request) -> bool: - """Return True when the request carries an ``Authorization: Bearer`` header. - - Matching is case-insensitive on the header name (via Starlette) and on the - ``Bearer`` scheme prefix. Bearer/OAuth2 token validation is performed - elsewhere in the request handling stack; this helper only gates CSRF logic. - - :param request: The incoming HTTP request. - :type request: Request - :return: True if the client sent a Bearer authorization scheme. - :rtype: bool - """ - return request.headers.get("authorization", "").lower().startswith("bearer ") - - -def _get_sep_settings() -> SEPSettings: - """Return the lazily-resolved SEP settings singleton. - - Deferred to break the circular import chain - ``config → middleware.__init__ → csrf → config``. - - :return: The SEP settings instance. - :rtype: SEPSettings - """ - from app.sep.config import sep_settings - - return sep_settings - - -class CSRFMiddleware(BaseHTTPMiddleware): - """Manage CSRF protection for HTTP requests and responses. - - On GET requests the middleware generates a signed CSRF token and exposes it - via ``request.state.csrf_token`` so templates can embed it in forms. The - token is also set as a cookie. Subsequent GETs reuse the existing cookie - value so multi-tab browsing remains stable. - - GET requests that include ``Authorization: Bearer`` skip token generation and - cookie setting; those clients do not rely on CSRF cookies. - """ - - def __init__(self, app: ASGIApp) -> None: - super().__init__(app) - - async def dispatch( - self, request: Request, call_next: RequestResponseEndpoint - ) -> Response: - """Generate and attach CSRF tokens on GET requests. - - :param request: The incoming HTTP request. - :type request: Request - :param call_next: The next middleware or endpoint in the ASGI application. - :type call_next: RequestResponseEndpoint - :return: The HTTP response, potentially with a CSRF cookie set. - :rtype: Response - """ - sep_settings = _get_sep_settings() - method = request.method.upper() - needs_cookie = False - - if method == "GET": - if request_has_bearer_authorization(request): - request.state.csrf_token = "" # nosec B105 - else: - session_cookie = request.cookies.get(sep_settings.SESSION.COOKIE_NAME) - existing_csrf = request.cookies.get(CSRF_COOKIE_NAME) - max_age = int(sep_settings.SESSION.MAX_AGE.total_seconds()) - - if existing_csrf: - try: - if session_cookie: - crypto_timestamp_serializer.loads( - existing_csrf, salt=session_cookie, max_age=max_age - ) - else: - crypto_timestamp_serializer.loads( - existing_csrf, max_age=max_age - ) - request.state.csrf_token = existing_csrf - except BadSignature: - existing_csrf = None - - if not existing_csrf: - nonce = secrets.token_hex(32) - if session_cookie: - token = crypto_timestamp_serializer.dumps( - nonce, salt=session_cookie - ) - else: - token = crypto_timestamp_serializer.dumps(nonce) - request.state.csrf_token = token - needs_cookie = True - - response = await call_next(request) - - endpoint = request.scope.get("endpoint") - if ( - method == "GET" - and needs_cookie - and not isinstance(endpoint, StaticFiles) - and not getattr(request.state, "is_csrf_exempt", False) - ): - response.set_cookie( - CSRF_COOKIE_NAME, - request.state.csrf_token, - httponly=True, - samesite="lax", - secure=sep_settings.SESSION.SECURE, - max_age=int(sep_settings.SESSION.MAX_AGE.total_seconds()), - ) - - return response diff --git a/app/sep/middleware/messages/__init__.py b/app/sep/middleware/messages/__init__.py deleted file mode 100644 index 59ed7ecaaa..0000000000 --- a/app/sep/middleware/messages/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define middleware to manage temporary messages stored in cookies.""" - -from app.sep.middleware.messages._middleware import MessagesMiddleware -from app.sep.middleware.messages._utils import ( - add_message, - error, - from_validation_error, - get_messages, - info, - success, - warning, -) diff --git a/app/sep/middleware/messages/_middleware.py b/app/sep/middleware/messages/_middleware.py deleted file mode 100644 index 7d392af254..0000000000 --- a/app/sep/middleware/messages/_middleware.py +++ /dev/null @@ -1,114 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define middleware to manage temporary messages stored in cookies.""" - -__all__ = ["MessagesMiddleware"] - -import json -import logging -from collections import OrderedDict -from typing import ClassVar - -from itsdangerous import BadPayload, BadSignature -from pydantic import ValidationError -from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint -from starlette.requests import Request -from starlette.responses import Response - -from app.core.security import crypto_serializer -from app.core.utils import json_serializer -from app.sep.middleware.messages.models import Message - -logger = logging.getLogger("app.sep.middleware.messages") - - -class MessagesMiddleware(BaseHTTPMiddleware): - """Manage temporary messages stored in cookies. - - This middleware retrieves messages stored in a cookie from the incoming request, - attaches them to the request state, and updates the response to include a cookie - with the current message queue. If the cookie size exceeds a maximum threshold, - older messages are discarded. - - :cvar MAX_COOKIE_SIZE: Maximum allowed size (in bytes) for the messages cookie. - Set to 1024. - :vartype MAX_COOKIE_SIZE: int - :cvar COOKIE_NAME: Name of the cookie used for storing messages. Set to "messages" - :vartype COOKIE_NAME: str - :cvar COOKIE_MAX_AGE: Maximum age (in seconds) for the messages cookie. Set to - 12 hours. - :vartype COOKIE_MAX_AGE: int - """ - - MAX_COOKIE_SIZE: ClassVar[int] = 1024 - COOKIE_NAME: ClassVar[str] = "messages" - COOKIE_MAX_AGE: ClassVar[int] = 60 * 60 * 12 - - async def dispatch( - self, request: Request, call_next: RequestResponseEndpoint - ) -> Response: - """Retrieve messages from cookies and update the response. - - This method parses the cookie (if present) to restore a list of messages into - the request state. After processing the request, it serializes any remaining - messages back into a cookie, ensuring that the cookie does not exceed the - maximum allowed size. - - :param request: The incoming HTTP request. - :type request: Request - :param call_next: The next callable in the middleware chain. - :type call_next: RequestResponseEndpoint - :return: The HTTP response with an updated messages cookie or with the cookie - removed, in case no message is left in the queue. - :rtype: Response - """ - request.state.messages = OrderedDict() - if old_cookie := request.cookies.get(self.COOKIE_NAME): - try: - request.state.messages = OrderedDict.fromkeys( - Message.model_validate(msg) - for msg in json.loads(crypto_serializer.loads(old_cookie)) - ) - except (BadSignature, BadPayload, json.JSONDecodeError, ValidationError): - logger.debug("Invalid messages cookie; treating as empty") - - response = await call_next(request) - while ( - len( - new_cookie := crypto_serializer.dumps( - json_serializer( - list(request.state.messages), - encoders_kwargs={"exclude_none": True}, - separators=(",", ":"), - ) - ) - ) - > self.MAX_COOKIE_SIZE - ) and request.state.messages: - discarded_msg, _ = request.state.messages.popitem(last=False) - logger.debug("Discarding message %s for %s", discarded_msg, request.client) - - if request.state.messages: - response.set_cookie( - key=self.COOKIE_NAME, - value=new_cookie, - httponly=True, - max_age=self.COOKIE_MAX_AGE, - ) - else: - response.delete_cookie(self.COOKIE_NAME) - - return response diff --git a/app/sep/middleware/messages/_utils.py b/app/sep/middleware/messages/_utils.py deleted file mode 100644 index 63fbdf5c37..0000000000 --- a/app/sep/middleware/messages/_utils.py +++ /dev/null @@ -1,268 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define functions for creating and retrieving messages for the messages middleware.""" - -__all__ = [ - "add_message", - "error", - "from_validation_error", - "get_messages", - "info", - "success", - "warning", -] - -import logging -from collections import OrderedDict -from collections.abc import Iterable - -from fastapi.exceptions import RequestValidationError -from pydantic import ValidationError -from starlette.requests import Request - -from app.core.utils.pydantic import loc_to_dot_sep -from app.sep.middleware.messages.config import messages_settings -from app.sep.middleware.messages.models import Message, MessageLevel - -logger = logging.getLogger("app.sep.middleware.messages") - - -def add_message( - request: Request, - level: MessageLevel, - text: str, - path_pattern: str | None = None, - *, - sticky: bool = False, -) -> None: - """Add a message to the current request's message queue. - - This function creates a new Message object with the provided level, text, and - sticky flag and appends it to the `messages` list in the request state. If the - request state does not support messages, an exception is logged. Messages with a - level lower than the configured minimum level in `messages_settings` are ignored. - - :param request: The HTTP request object. - :type request: Request - :param level: The level of the message. - :type level: MessageLevel - :param text: The text content of the message. - :type text: str - :param path_pattern: An optional path regex pattern associated with the message. - If provided, the message will only be shown on requests matching this pattern. - :type path_pattern: str | None - :param sticky: Whether the message should persist until explicitly dismissed, - defaults to False. - :type sticky: bool - :raises AttributeError: If the request does not have a messages attribute. - :raises ValueError: If the message data is invalid and fails validation. - """ - if level >= messages_settings.LEVEL: - try: - message = Message( - level=level, text=text, path_pattern=path_pattern, sticky=sticky - ) - request.state.messages[message] = None - except AttributeError: - logger.exception("Unable to add messages without MessageMiddleware") - except ValueError: - logger.exception("Error building Message object") - else: - logger.debug("Sending message to %s: %s", request.client.host, message) - else: - logger.debug( - "Ignoring message below configured level %s: %s", - messages_settings.LEVEL, - level, - ) - - -def get_messages(request: Request, max_qty: int | None = None) -> list[Message]: - """Retrieve and remove messages from the request state. - - This function fetches messages stored in the request state (up to `max_qty` if - specified), removes them from the state, and returns them as a list. Messages with - non-nullable path patterns that doesn't match the current requested path are - retained in the state for future retrieval. - - :param request: The HTTP request object. - :type request: Request - :param max_qty: Optional maximum number of messages to retrieve. If None (default), - all messages are returned. - :type max_qty: int | None - :return: A list of Message objects. - :rtype: list[Message] - """ - messages = [] - messages_to_save = [] - saved_messages = getattr(request.state, "messages", None) - current_path = request.scope.get("path_pattern") or request.url.path - logger.debug("Retrieving messages for %s: %s", current_path, saved_messages) - while saved_messages and (max_qty is None or len(messages) < max_qty): - message, _ = saved_messages.popitem(last=False) - if message.path_pattern is None or message.path_pattern.match(current_path): - messages.append(message) - else: - messages_to_save.append(message) - logger.debug("Retrieved messages for %s: %s", current_path, messages) - logger.debug("Saving messages for %s: %s", current_path, messages_to_save) - request.state.messages = OrderedDict.fromkeys( - messages_to_save + list(saved_messages or []) - ) - return messages - - -def info( - request: Request, - text: str, - path_pattern: str | None = None, - *, - sticky: bool = False, -) -> None: - """Add an informational message to the request's message queue. - - This is a convenience function that adds a message with the INFO level. - - :param request: The HTTP request object. - :type request: Request - :param text: The informational message text. - :type text: str - :param path_pattern: An optional path regex pattern associated with the message. - If provided, the message will only be shown on requests matching this pattern. - :type path_pattern: str | None - :param sticky: Whether the message should persist until explicitly dismissed, - defaults to False. - :type sticky: bool - """ - add_message(request, MessageLevel.INFO, text, path_pattern, sticky=sticky) - - -def success( - request: Request, - text: str, - path_pattern: str | None = None, - *, - sticky: bool = False, -) -> None: - """Add a success message to the request's message queue. - - This is a convenience function that adds a message with the SUCCESS level. - - :param request: The HTTP request object. - :type request: Request - :param text: The success message text. - :type text: str - :param path_pattern: An optional path regex pattern associated with the message. - If provided, the message will only be shown on requests matching this pattern. - :type path_pattern: str | None - :param sticky: Whether the message should persist until explicitly dismissed, - defaults to False. - :type sticky: bool - """ - add_message(request, MessageLevel.SUCCESS, text, path_pattern, sticky=sticky) - - -def warning( - request: Request, - text: str, - path_pattern: str | None = None, - *, - sticky: bool = False, -) -> None: - """Add a warning message to the request's message queue. - - This is a convenience function that adds a message with the WARNING level. - - :param request: The HTTP request object. - :type request: Request - :param text: The warning message text. - :type text: str - :param path_pattern: An optional path regex pattern associated with the message. - If provided, the message will only be shown on requests matching this pattern. - :type path_pattern: str | None - :param sticky: Whether the message should persist until explicitly dismissed, - defaults to False. - :type sticky: bool - """ - add_message(request, MessageLevel.WARNING, text, path_pattern, sticky=sticky) - - -def error( - request: Request, - text: str, - path_pattern: str | None = None, - *, - sticky: bool = False, -) -> None: - """Add an error message to the request's message queue. - - This is a convenience function that adds a message with the ERROR level. - - :param request: The HTTP request object. - :type request: Request - :param text: The error message text. - :type text: str - :param path_pattern: An optional path regex pattern associated with the message. - If provided, the message will only be shown on requests matching this pattern. - :type path_pattern: str | None - :param sticky: Whether the message should persist until explicitly dismissed, - defaults to False. - :type sticky: bool - """ - add_message(request, MessageLevel.ERROR, text, path_pattern, sticky=sticky) - - -def from_validation_error( - request: Request, - exc: ValidationError | RequestValidationError, - base_text: str = "Error", - path_pattern: str | None = None, - *, - sticky: bool = False, - exclude_types: Iterable[str] = (), -) -> None: - """Add error messages to the request from a validation error. - - Accepts either Pydantic's :class:`ValidationError` (raised by manual - ``model_validate`` calls in dependencies) or FastAPI's - :class:`RequestValidationError` (raised by the framework when ``Form()``, - ``Body()``, or ``Query()`` parameters fail to bind). Both expose the same - ``.errors()`` shape, so the iteration below is identical. - - :param request: The HTTP request object. - :type request: Request - :param exc: The validation error instance. - :type exc: ValidationError | RequestValidationError - :param base_text: The base text for the error messages, defaults to "Error". - :type base_text: str - :param path_pattern: An optional path regex pattern associated with the messages. - If provided, the messages will only be shown on requests matching this pattern. - :type path_pattern: str | None - :param sticky: Whether the messages should persist until explicitly dismissed, - defaults to False. - :type sticky: bool - :param exclude_types: An iterable of error types to exclude from messaging. - Defaults to an empty tuple. - :type exclude_types: Iterable[str] - """ - for err in exc.errors(): - if err.get("type") not in exclude_types: - err_msg = base_text - if err_loc := err.get("loc", ()): - err_msg += f" [{loc_to_dot_sep(err_loc)}]" - if err_msg_detail := err.get("msg"): - err_msg += f": {err_msg_detail}" - error(request, err_msg, path_pattern, sticky=sticky) diff --git a/app/sep/middleware/messages/config.py b/app/sep/middleware/messages/config.py deleted file mode 100644 index 55cd0c158c..0000000000 --- a/app/sep/middleware/messages/config.py +++ /dev/null @@ -1,57 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define settings for the messages middleware.""" - -from typing import ClassVar - -from pydantic import NonNegativeInt - -from app.core.config import BaseYamlSettings -from app.core.settings_override.models import SettingClassEnum -from app.core.settings_override.proxy import OverridableSettingsProxy -from app.core.settings_override.registry import hot_field -from app.sep.middleware.messages.models import MessageLevel - -MESSAGE_NOTSET_LEVEL = 0 - - -class MessagesSettings(BaseYamlSettings): - """Define configuration options for the messages middleware. - - Wrapped in :class:`OverridableSettingsProxy` below, which defers - validation to first attribute access. ``app.sep.main.sep_lifespan`` - calls ``messages_settings._resolve()`` at startup to restore the - fail-fast validation the pre-proxy eager ``MessagesSettings()`` - construction provided -- removing or relocating that call regresses to - lazy validation, surfacing config errors at first request instead of - startup. - - :cvar SETTINGS_PREFIXES: The prefixes for snippets related settings in the - configuration file. Set to `["SEP", "MESSAGES"]`. - :vartype SETTINGS_PREFIXES: ClassVar[list[str]] - :param LEVEL: The minimum level of messages to be stored and displayed. - Messages with a level lower than this will be ignored. Set to - `MESSAGE_NOTSET_LEVEL` (0) by default, which means all messages are stored. - :type LEVEL: MessageLevel | NonNegativeInt - """ - - SETTINGS_PREFIXES: ClassVar[list[str]] = ["SEP", "MESSAGES"] - LEVEL: MessageLevel | NonNegativeInt = hot_field(MESSAGE_NOTSET_LEVEL) - - -messages_settings: MessagesSettings = OverridableSettingsProxy( - MessagesSettings, setting_class=SettingClassEnum.MESSAGES_SETTINGS -) diff --git a/app/sep/middleware/messages/models.py b/app/sep/middleware/messages/models.py deleted file mode 100644 index 5ee39958ea..0000000000 --- a/app/sep/middleware/messages/models.py +++ /dev/null @@ -1,117 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define models for the messages middleware.""" - -__all__ = ["Message", "MessageLevel"] - -import re -from enum import IntEnum -from typing import Any - -from pydantic import ( - BaseModel, - ConfigDict, - Field, - field_serializer, - model_validator, -) - -from app.core.utils.fields import EnumFieldMixin, NonEmptyStr - - -class MessageLevel(EnumFieldMixin, IntEnum): - """Enumerate the possible message levels. - - :cvar INFO: Represents a standard information message. - :vartype INFO: int - :cvar SUCCESS: Represents a success message. - :vartype SUCCESS: int - :cvar WARNING: Represents a warning message. - :vartype WARNING: int - :cvar ERROR: Represents an error message. - :vartype ERROR: int - """ - - INFO = 1 - SUCCESS = 3 - WARNING = 5 - ERROR = 7 - - -class Message(BaseModel): - """Represent a user-facing message to be displayed in the UI. - - :param level: The level of the message. - :type level: MessageLevel - :param text: The content of the message. Must not exceed 512 characters. - :type text: str - :param path_pattern: An optional URI path pattern associated with the message. - If provided, the message will only be shown on requests matching this pattern. - :type path_pattern: re.Pattern[str] | None - :param sticky: A flag indicating whether the message should persist until dismissed. - :type sticky: bool - """ - - model_config = ConfigDict(populate_by_name=True) - level: MessageLevel = Field(alias="l") - text: NonEmptyStr = Field(alias="t", max_length=512) - path_pattern: re.Pattern[str] | None = Field(default=None, alias="p") - sticky: bool = Field(default=False, exclude=True) - - def __hash__(self) -> int: - """Return a hash of the message based on its level and text. - - This method allows Message objects to be used in sets or as dictionary keys. - - :return: A hash value for the message. - :rtype: int - """ - return hash((self.level, self.text, self.sticky)) - - @model_validator(mode="before") - @classmethod - def set_sticky_from_level(cls, data: Any) -> Any: - """Pre-process input data to set the 'sticky' flag based on the message level. - - This method allows validation for messages serialized with the `serialize_level` - serializer, in which the `sticky` attribute is defined in the `level` by adding - 1 to the value. - - :param data: The raw data to be validated, typically a dictionary. - :type data: Any - :return: The processed data with a potentially updated 'sticky' attribute. - :rtype: Any - """ - if isinstance(data, dict) and "sticky" not in data: - level = data.get("l") - if isinstance(level, int) and not level % 2: - data["l"] -= 1 - data["sticky"] = True - return data - - @field_serializer("level") - def serialize_level(self, level: MessageLevel) -> int: - """Serialize the message level by incorporating the sticky flag. - - In order to minimize the messages cookie, the `sticky` attribute is defined - in the `level` by adding 1 to the original `level` value. - - :param level: The original message level. - :type level: MessageLevel - :return: The serialized level as an integer, plus 1 if `sticky` is True. - :rtype: int - """ - return level + self.sticky diff --git a/app/sep/migrations/versions/2026_08_08_0306-cf94cc04be4b_drop_session_and_messages_setting_.py b/app/sep/migrations/versions/2026_08_08_0306-cf94cc04be4b_drop_session_and_messages_setting_.py new file mode 100644 index 0000000000..274b866d0f --- /dev/null +++ b/app/sep/migrations/versions/2026_08_08_0306-cf94cc04be4b_drop_session_and_messages_setting_.py @@ -0,0 +1,127 @@ +# Copyright (C) 2026 Percona LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""drop session and messages setting overrides + +Revision ID: cf94cc04be4b +Revises: a2b3c4d5e6f7 +Create Date: 2026-08-08 03:06:15.040695 + +""" + +import sqlalchemy as sa +from alembic import op + +from app.core.db.utils import ( + acquire_pg_advisory_xact_lock, + check_constraint_lists_members, + table_exists, +) +from app.core.settings_override.constants import SETTINGOVERRIDE_MIGRATION_LOCK_KEY + +# revision identifiers, used by Alembic. +revision = "cf94cc04be4b" +down_revision = "a2b3c4d5e6f7" +branch_labels = None +depends_on = None + +_OLD_MEMBERS = ( + "SEP_SETTINGS", + "TASKS_SETTINGS", + "SNIPPETS_SETTINGS", + "MESSAGES_SETTINGS", + "SETTINGS", + "ALERT_SETTINGS", + "ANONYMIZER_SETTINGS", + "ALERTS_SETTINGS", + "INVENTORY_SETTINGS", +) +_NEW_MEMBERS = tuple(m for m in _OLD_MEMBERS if m != "MESSAGES_SETTINGS") + +# ``_`` is a LIKE wildcard, so the doubled underscore of the nested-leaf prefix +# must be escaped -- an unescaped ``SESSION__%`` also matches ``SESSION_REFRESH`` +# leaves, which this ticket keeps. +_DELETE_SESSION_ROWS = ( + "DELETE FROM settingoverride WHERE setting_class = 'SEP_SETTINGS'" + r" AND (key = 'SESSION' OR key LIKE 'SESSION\_\_%' ESCAPE '\')" +) +_DELETE_MESSAGES_ROWS = ( + "DELETE FROM settingoverride WHERE setting_class = 'MESSAGES_SETTINGS'" +) + + +def upgrade() -> None: + """Drop the retired ``SESSION`` / ``MessagesSettings`` override rows.""" + bind = op.get_bind() + acquire_pg_advisory_xact_lock(bind, SETTINGOVERRIDE_MIGRATION_LOCK_KEY) + op.execute(_DELETE_SESSION_ROWS) + if not check_constraint_lists_members( + bind, "settingoverride", "setting_class", ("MESSAGES_SETTINGS",) + ): + return + # The narrowed CHECK re-validates existing data, so the rows carrying the + # removed member have to go first or the ALTER aborts. + op.execute(_DELETE_MESSAGES_ROWS) + with op.batch_alter_table("settingoverride", schema=None) as batch_op: + batch_op.alter_column( + "setting_class", + existing_type=sa.Enum( + *_OLD_MEMBERS, + name="settingclassenum", + native_enum=False, + create_constraint=True, + ), + type_=sa.Enum( + *_NEW_MEMBERS, + name="settingclassenum", + native_enum=False, + create_constraint=True, + ), + existing_nullable=False, + ) + + +def downgrade() -> None: + """Restore ``MESSAGES_SETTINGS`` in the setting_class constraint. + + The deleted rows are not recreated: they configured a session cookie and a + flash-message level that no longer exist, so restoring them would carry no + meaning. + """ + bind = op.get_bind() + acquire_pg_advisory_xact_lock(bind, SETTINGOVERRIDE_MIGRATION_LOCK_KEY) + if not table_exists(bind, "settingoverride"): + return + if check_constraint_lists_members( + bind, "settingoverride", "setting_class", ("MESSAGES_SETTINGS",) + ): + return + with op.batch_alter_table("settingoverride", schema=None) as batch_op: + batch_op.alter_column( + "setting_class", + existing_type=sa.Enum( + *_NEW_MEMBERS, + name="settingclassenum", + native_enum=False, + create_constraint=True, + ), + type_=sa.Enum( + *_OLD_MEMBERS, + name="settingclassenum", + native_enum=False, + create_constraint=True, + ), + existing_nullable=False, + ) diff --git a/app/sep/routes/download_files.py b/app/sep/routes/download_files.py index 6efab784d5..adfd6ee0dc 100644 --- a/app/sep/routes/download_files.py +++ b/app/sep/routes/download_files.py @@ -25,13 +25,12 @@ from starlette.responses import StreamingResponse from app.sep.deps import ( - CurrentUser, + ApiCurrentUser, get_task_history, - IsAuthenticated, + IsApiAuthenticated, TaskAPI, TasksClient, ) -from app.sep.utils.decorators import csrf_exempt from app.tasks.models import FileMetadata, TaskHistoryResponse logger = logging.getLogger(__name__) @@ -40,9 +39,8 @@ @router.get( "/{task_history_id}", - dependencies=[IsAuthenticated], + dependencies=[IsApiAuthenticated], ) -@csrf_exempt async def list_task_history_files( request: Request, # noqa: ARG001 task_history: Annotated[TaskHistoryResponse, Depends(get_task_history)], @@ -60,11 +58,10 @@ async def list_task_history_files( raise -@router.get("/{task_history_id}/download", dependencies=[IsAuthenticated]) -@csrf_exempt +@router.get("/{task_history_id}/download", dependencies=[IsApiAuthenticated]) async def download_task_history_file( request: Request, - user: CurrentUser, + user: ApiCurrentUser, task_history: Annotated[TaskHistoryResponse, Depends(get_task_history)], tasks_client: TasksClient, ) -> StreamingResponse: diff --git a/app/sep/routes/execution_events.py b/app/sep/routes/execution_events.py index ee5a73a68e..e03b912a61 100644 --- a/app/sep/routes/execution_events.py +++ b/app/sep/routes/execution_events.py @@ -22,10 +22,9 @@ from app.sep.deps import ( get_task_history, - IsAuthenticated, + IsApiAuthenticated, TaskAPI, ) -from app.sep.utils.decorators import csrf_exempt from app.tasks.models import ExecutionEvent, TaskHistoryResponse router = APIRouter(tags=["tasks"]) @@ -33,9 +32,8 @@ @router.get( "/{task_history_id}", - dependencies=[IsAuthenticated], + dependencies=[IsApiAuthenticated], ) -@csrf_exempt async def list_task_execution_events( request: Request, # noqa: ARG001 task_history: Annotated[TaskHistoryResponse, Depends(get_task_history)], diff --git a/app/sep/routes/inventory_ajax.py b/app/sep/routes/inventory_ajax.py deleted file mode 100644 index ee51a156c9..0000000000 --- a/app/sep/routes/inventory_ajax.py +++ /dev/null @@ -1,83 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define legacy AJAX proxy routes for inventory API schema/table data. - -Serve dynamic dropdown data to Jinja2 forms that predate the shared -``/api/apps/*`` surface. New plugin API endpoints should register under -``app/sep/api/router.py`` instead of being added here. -""" - -import logging - -from fastapi import APIRouter, HTTPException, Request -from fastapi.responses import JSONResponse - -from app.core.pagination import fetch_all_dict_items -from app.sep.apps.framework.deprecation import DeprecatedJinja2Route -from app.sep.deps import InventoryAPI, IsAuthenticated -from app.sep.utils.decorators import csrf_exempt - -logger = logging.getLogger(__name__) - -router = APIRouter(route_class=DeprecatedJinja2Route) - - -@router.get("/services/{service_id}/schemas", dependencies=[IsAuthenticated]) -@csrf_exempt -async def list_schemas( - request: Request, # noqa: ARG001 - service_id: int, - inventory_api: InventoryAPI, - search: str | None = None, -) -> JSONResponse: - """Return schemas for a service as JSON for AJAX dropdowns.""" - try: - items = await fetch_all_dict_items( - lambda pagination: inventory_api.get( - f"/services/{service_id}/schemas/", - params={ - **pagination.model_dump(), - **({"search": search} if search else {}), - }, - ) - ) - except HTTPException: - return JSONResponse([]) - return JSONResponse([{"id": s["id"], "name": s["name"]} for s in items]) - - -@router.get("/schemas/{schema_id}/tables", dependencies=[IsAuthenticated]) -@csrf_exempt -async def list_tables( - request: Request, # noqa: ARG001 - schema_id: int, - inventory_api: InventoryAPI, - search: str | None = None, -) -> JSONResponse: - """Return tables for a schema as JSON for AJAX dropdowns.""" - try: - items = await fetch_all_dict_items( - lambda pagination: inventory_api.get( - f"/schemas/{schema_id}/tables/", - params={ - **pagination.model_dump(), - **({"search": search} if search else {}), - }, - ) - ) - except HTTPException: - return JSONResponse([]) - return JSONResponse([{"id": t["id"], "name": t["name"]} for t in items]) diff --git a/app/sep/routes/periodic_tasks.py b/app/sep/routes/periodic_tasks.py deleted file mode 100644 index 1cdffe7054..0000000000 --- a/app/sep/routes/periodic_tasks.py +++ /dev/null @@ -1,89 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define routes for scheduling tasks.""" - -import logging -from typing import Annotated - -from fastapi import APIRouter, Form, Header, status -from fastapi.responses import RedirectResponse - -from app.core.utils import deep_dict_update -from app.sep.apps.framework.deprecation import DeprecatedJinja2Route -from app.sep.deps import IsAuthenticated, IsCsrfValidated, TaskAPI -from app.sep.tasks import ( - EnhancedPeriodicTaskCreateRequest, - PeriodicTaskRequest, -) - -logger = logging.getLogger(__name__) - -router = APIRouter(route_class=DeprecatedJinja2Route) - - -@router.post( - "/", - dependencies=[IsAuthenticated, IsCsrfValidated], -) -async def periodic_task_create( - tasks_api: TaskAPI, - periodic_task: Annotated[EnhancedPeriodicTaskCreateRequest, Form()], - referer: Annotated[str, Header()] = "/tasks", -) -> RedirectResponse: - """Create periodic task.""" - periodic_task_data = periodic_task.model_dump(exclude_none=True, exclude={"task"}) - logger.debug("Scheduling task %s, %s,", periodic_task.task, periodic_task_data) - await tasks_api.post(f"/{periodic_task.task}/periodic/", json=periodic_task_data) - return RedirectResponse(referer, status_code=status.HTTP_303_SEE_OTHER) - - -@router.post( - "/{periodic_task_id}/delete", - dependencies=[IsAuthenticated, IsCsrfValidated], - response_class=RedirectResponse, -) -async def periodic_task_delete( - periodic_task_id: str, - tasks_api: TaskAPI, - referer: Annotated[str, Header()] = "/tasks", -) -> RedirectResponse: - """Delete Periodic task.""" - logger.debug("Deleting Periodic task %s", periodic_task_id) - await tasks_api.delete(f"/periodic/{periodic_task_id}") - return RedirectResponse(referer, status_code=status.HTTP_303_SEE_OTHER) - - -@router.post( - "/{periodic_task_id}/update", dependencies=[IsAuthenticated, IsCsrfValidated] -) -async def periodic_task_update( - periodic_task_id: int, - tasks_api: TaskAPI, - updated_task: Annotated[PeriodicTaskRequest, Form()], - referer: Annotated[str, Header()] = "/tasks", -) -> RedirectResponse: - """Update periodic task.""" - updated_task_data = updated_task.model_dump(exclude_unset=True) - if updated_task_data: - periodic_task_data = await tasks_api.get(f"/periodic/{periodic_task_id}") - execute_request_payload = updated_task_data.get("execute_request") - if execute_request_payload is not None: - periodic_task_data["execute_request"] = updated_task_data.pop( - "execute_request" - ) - deep_dict_update(periodic_task_data, updated_task_data) - await tasks_api.put(f"/periodic/{periodic_task_id}", json=periodic_task_data) - return RedirectResponse(referer, status_code=status.HTTP_303_SEE_OTHER) diff --git a/app/sep/routes/stop_task.py b/app/sep/routes/stop_task.py deleted file mode 100644 index 7522d63350..0000000000 --- a/app/sep/routes/stop_task.py +++ /dev/null @@ -1,49 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define route for stopping tasks.""" - -import logging -from typing import Annotated - -from fastapi import APIRouter, Depends, Header, Request, status -from starlette.responses import RedirectResponse - -from app.sep.apps.framework.deprecation import DeprecatedJinja2Route -from app.sep.deps import get_task_history, IsAuthenticated, IsCsrfValidated, TaskAPI -from app.sep.middleware import messages -from app.tasks.models import TaskHistoryResponse, TaskHistoryStatusEnum - -logger = logging.getLogger(__name__) -router = APIRouter(route_class=DeprecatedJinja2Route) - - -@router.post("/{task_history_id}", dependencies=[IsAuthenticated, IsCsrfValidated]) -async def stop_task_execution( - request: Request, - task_history: Annotated[TaskHistoryResponse, Depends(get_task_history)], - tasks_api: TaskAPI, - referer: Annotated[str, Header()] = "/", -) -> RedirectResponse: - """Stop a task history.""" - logger.debug("Stopping task history %s", task_history.id) - task_history = await tasks_api.post(f"/history/{task_history.id}/stop/") - task_name = task_history["task"]["name"] - task_status = task_history["status"] - if task_status == TaskHistoryStatusEnum.STOPPED: - messages.success(request, f"Task '{task_name}' has been stopped successfully.") - else: - messages.success(request, f"Task '{task_name}' canceled before execution.") - return RedirectResponse(referer, status_code=status.HTTP_303_SEE_OTHER) diff --git a/app/sep/routes/stream_logs.py b/app/sep/routes/stream_logs.py index 2ed4353915..35316cf3ce 100644 --- a/app/sep/routes/stream_logs.py +++ b/app/sep/routes/stream_logs.py @@ -26,23 +26,21 @@ from starlette.responses import StreamingResponse from app.sep.deps import ( - CurrentUser, + ApiCurrentUser, get_task_history, - IsAuthenticated, + IsApiAuthenticated, TasksClient, ) -from app.sep.utils.decorators import csrf_exempt from app.tasks.models import TaskHistoryResponse, TaskHistoryStatusEnum logger = logging.getLogger(__name__) router = APIRouter(tags=["tasks"]) -@router.get("/{task_history_id}/execution-events", dependencies=[IsAuthenticated]) -@csrf_exempt +@router.get("/{task_history_id}/execution-events", dependencies=[IsApiAuthenticated]) async def task_execution_events_stream( request: Request, - user: CurrentUser, + user: ApiCurrentUser, task_history: Annotated[TaskHistoryResponse, Depends(get_task_history)], tasks_client: TasksClient, ) -> StreamingResponse: @@ -132,16 +130,14 @@ async def task_history_events_event_stream( yield f"event: sep-error\ndata: {json.dumps({'detail': str(exc)})}\n\n" -@router.get("/{task_history_id}", dependencies=[IsAuthenticated]) -@csrf_exempt +@router.get("/{task_history_id}", dependencies=[IsApiAuthenticated]) async def task_logs_event_stream( request: Request, - user: CurrentUser, + user: ApiCurrentUser, task_history: Annotated[TaskHistoryResponse, Depends(get_task_history)], tasks_client: TasksClient, ) -> StreamingResponse: """Stream a task history's logs as server-sent events.""" - logger.debug("request.state.is_csrf_exempt is %s", request.state.is_csrf_exempt) return StreamingResponse( task_history_logs_event_stream( tasks_client, task_history.id, request, user.access_token diff --git a/app/sep/settings_override.py b/app/sep/settings_override.py index 849c2d0b9f..7124fc7229 100644 --- a/app/sep/settings_override.py +++ b/app/sep/settings_override.py @@ -45,7 +45,6 @@ from app.sep.apps.framework.registry import collect_app_owned_settings_classes from app.sep.config import sep_settings, SEPSettings from app.sep.db import get_async_session_maker -from app.sep.middleware.messages.config import messages_settings, MessagesSettings from app.sep.snippets.config import snippets_settings, SnippetsSettings @@ -82,9 +81,6 @@ def build_sep_override_proxies() -> ProxyRegistry: SettingClassEnum.SNIPPETS_SETTINGS: ProxyEntry( snippets_settings, SnippetsSettings ), - SettingClassEnum.MESSAGES_SETTINGS: ProxyEntry( - messages_settings, MessagesSettings - ), SettingClassEnum.SETTINGS: ProxyEntry(settings, Settings), SettingClassEnum.ALERT_SETTINGS: ProxyEntry(alert_settings, AlertSettings), } @@ -169,15 +165,6 @@ def start_sep_settings_override_refresher(**_: Any) -> None: the periodic refresher still starts and the child runs with env-only overrides until a later cycle lands. - ``messages_settings._resolve()`` runs unconditionally for validation even - when the refresher is disabled, as ``sep_overrides_lifespan`` does. The two - differ in consequence: the lifespan's call aborts startup, while Celery - catches and logs whatever a signal receiver raises, so an invalid config - here leaves the child running without this refresher. - - :raises ValidationError: Propagates from ``messages_settings._resolve()`` - when the messages config is invalid. Celery logs it and carries on - dispatching; the child starts without this refresher. :raises Exception: Propagates whatever composing the proxy registry or the initial inline refresh raises -- a malformed app-owned declaration (``TypeError`` / ``ValueError``) or a session-maker failure -- and is @@ -185,7 +172,6 @@ def start_sep_settings_override_refresher(**_: Any) -> None: expiry are caught and logged inside the refresher; the latter still starts the periodic task. """ - messages_settings._resolve() # noqa: SLF001 _refresher.start( callbacks=WORKER_OVERRIDE_CALLBACKS, proc_alive_timeout=celery.conf.worker_proc_alive_timeout, diff --git a/app/sep/snippets/deps.py b/app/sep/snippets/deps.py index b28a250aea..0f7ef01fee 100644 --- a/app/sep/snippets/deps.py +++ b/app/sep/snippets/deps.py @@ -21,27 +21,16 @@ from pathlib import Path, PurePosixPath from typing import Annotated -from fastapi import Depends, Header, Query, Request, status -from pydantic import ValidationError +from fastapi import Depends, Query from sqlmodel import col from sqlmodel.ext.asyncio.session import AsyncSession from app.core.auth.exceptions import HTTPForbiddenException from app.core.db.list_query import ListQuery, make_list_query_dep -from app.core.exceptions import ( - HTTPBadRequestException, - HTTPNotFoundException, - HTTPRedirectException, -) -from app.core.utils import remove_falsy_values_from_dict -from app.sep.apps.framework.script_helpers import ( - build_artifact_download_url, - build_execution_meta, -) +from app.core.exceptions import HTTPBadRequestException, HTTPNotFoundException +from app.sep.apps.framework.script_helpers import build_execution_meta from app.sep.deps import SessionDep -from app.sep.middleware import messages from app.sep.snippets.config import snippets_settings -from app.sep.snippets.constants import ARTIFACT_TYPE_SNIPPET from app.sep.snippets.crud import SnippetManager from app.sep.snippets.list_query import SnippetApprovalFilter, SnippetListQuery from app.sep.snippets.models.responses import ( @@ -53,7 +42,6 @@ Snippet, SnippetExecutionMeta, ) -from app.sep.snippets.schema import evaluate_snippet_gates logger = logging.getLogger(__name__) @@ -147,211 +135,6 @@ async def get_snippet( SnippetDep = Annotated[Snippet, Depends(get_snippet)] -def validate_snippet_parameters(request: Request, snippet: SnippetDep) -> Snippet: - """Validate the parameters of an approved snippet and add warnings to the request. - - If the snippet is approved, validate its parameters and add any validation errors - as warning messages to the request. - - :param request: The HTTP request object. - :type request: Request - :param snippet: The snippet to validate. - :type snippet: Snippet - :return: The validated snippet. - :rtype: Snippet - """ - if snippet.is_approved: - add_msg_func = ( - messages.warning - if snippets_settings.META.IGNORE_INVALID_PARAMETERS - else messages.error - ) - for error in snippet.validated_parameters.errors: - add_msg_func(request, error) - return snippet - - -ValidatedSnippet = Annotated[Snippet, Depends(validate_snippet_parameters)] - - -def _get_snippet_redirect_exc( - request: Request, referer: str | None, msg: str | None = None -) -> HTTPRedirectException: - if msg: - messages.error(request, msg) - location = referer or request.url_for("snippets_index") - return HTTPRedirectException( - location=location, status_code=status.HTTP_303_SEE_OTHER - ) - - -def get_approved_snippet( - request: Request, - snippet: SnippetDep, - referer: Annotated[str | None, Header()] = None, -) -> Snippet: - """Verify if a snippet is approved before returning it. - - If the snippet is not approved, add an error message to the request and raise an - HTTPRedirectException back to the referer. - - :param request: The HTTP request object. - :type request: Request - :param snippet: The snippet to verify the approval. - :type snippet: Snippet - :param referer: The referer URL. If None is specified, it defaults to the - snippets_index route. - :type referer: str | None - :return: The retrieved snippet. - :rtype: Snippet - :raises HTTPRedirectException: If the snippet is not approved. - """ - if snippet.is_approved: - return snippet - raise _get_snippet_redirect_exc( - request, referer, f"Snippet {snippet} is not approved" - ) - - -ApprovedSnippet = Annotated[Snippet, Depends(get_approved_snippet)] - - -def get_unapproved_snippet( - request: Request, - snippet: SnippetDep, - referer: Annotated[str | None, Header()] = None, -) -> Snippet: - """Verify if a snippet is unapproved before returning it. - - If the snippet is approved, add an error message to the request and raise an - HTTPRedirectException back to the referer. - - :param request: The HTTP request object. - :type request: Request - :param snippet: The snippet to verify the approval. - :type snippet: Snippet - :param referer: The referer URL. If None is specified, it defaults to the - snippets_index route. - :type referer: str | None - :return: The retrieved snippet. - :rtype: Snippet - :raises HTTPRedirectException: If the snippet is approved. - """ - if not snippet.is_approved: - return snippet - raise _get_snippet_redirect_exc( - request, referer, f"Snippet {snippet} is already approved" - ) - - -UnapprovedSnippet = Annotated[Snippet, Depends(get_unapproved_snippet)] - - -def get_executable_snippet( - request: Request, - snippet: SnippetDep, - referer: Annotated[str | None, Header()] = None, -) -> Snippet: - """Verify if a snippet can be executed before returning it. - - If the snippet cannot be executed, add an error message to the request and raise an - HTTPRedirectException back to the referer. - - :param request: The HTTP request object. - :type request: Request - :param snippet: The snippet to verify if it can be executed. - :type snippet: Snippet - :param referer: The referer URL. If None is specified, it defaults to the - snippets_index route. - :type referer: str | None - :return: The retrieved snippet. - :rtype: Snippet - :raises HTTPRedirectException: If the snippet cannot be executed. - """ - if snippet.can_execute: - return snippet - raise _get_snippet_redirect_exc( - request, referer, f"Snippet {snippet} cannot be executed" - ) - - -ExecutableSnippet = Annotated[Snippet, Depends(get_executable_snippet)] - - -def get_snippet_source(request: Request, snippet: SnippetDep) -> str: - """Return a signed URL for Nomad to download the snippet artifact. - - :param request: The HTTP request object. - :type request: Request - :param snippet: The snippet to generate the signed download URL for. - :type snippet: Snippet - :return: The signed URL to download the snippet artifact. - :rtype: str - """ - return build_artifact_download_url( - request, - artifact_type=ARTIFACT_TYPE_SNIPPET, - filename=snippet.filename, - md5_digest=snippet.md5_digest, - ) - - -SnippetSource = Annotated[str, Depends(get_snippet_source)] - - -async def get_validated_execution_args( - request: Request, - snippet: ExecutableSnippet, - referer: Annotated[str | None, Header()] = None, -) -> BaseSnippetArgs: - """Validate and return the execution arguments for an executable snippet. - - :param request: The HTTP request object. - :type request: Request - :param snippet: The executable snippet. - :type snippet: Snippet - :param referer: The referer URL. If None is specified, it defaults to the - snippets_index route. - :type referer: str | None - :return: The validated execution arguments. - :rtype: BaseSnippetArgs - :raises HTTPRedirectException: When dynamic validation fails, or a snippet - field gate fires given the rest of the submission — a value submitted for - a parameter whose visibility (``visible_when`` / ``visible_when_not``) or - ``forbidden`` (``forbidden_when`` / ``forbidden_when_not``) gate fires, or - a parameter omitted while its ``requires`` (``requires_when`` / - ``requires_when_not``) gate fires. The submission is rejected rather than - silently stripped, matching ``@apply_conditional_rules`` for hand-coded - plugins. - """ - execution_model = snippet.get_execution_model() - async with request.form() as form: - form_data = dict(form) - logger.debug( - "Validating execution args for snippet %r: %s", snippet.filename, form_data - ) - try: - execution_args = execution_model.model_validate( - remove_falsy_values_from_dict(form_data) - ) - except ValidationError as exc: - messages.from_validation_error( - request, - exc, - "Error executing snippet", - None, - exclude_types=("none_required",), - ) - raise _get_snippet_redirect_exc(request, referer) from None - - gate_failures = evaluate_snippet_gates(snippet, execution_args) - if gate_failures: - messages.error(request, "Error executing snippet: " + "; ".join(gate_failures)) - raise _get_snippet_redirect_exc(request, referer) - - return execution_args - - def build_snippet_execution_meta( snippet: Snippet, execution_args: BaseSnippetArgs, @@ -386,59 +169,6 @@ def build_snippet_execution_meta( ) -def get_snippet_execution_request_meta( - snippet: ExecutableSnippet, - snippet_source: SnippetSource, - execution_args: Annotated[BaseSnippetArgs, Depends(get_validated_execution_args)], -) -> SnippetExecutionMeta: - """Prepare and return the execution metadata for a snippet execution request. - - :param snippet: The executable snippet. - :type snippet: Snippet - :param snippet_source: The signed URL to download the snippet artifact. - :type snippet_source: str - :param execution_args: The execution arguments for the snippet. - :type execution_args: BaseSnippetArgs - :return: The prepared execution metadata. - :rtype: SnippetExecutionMeta - """ - return build_snippet_execution_meta(snippet, execution_args, snippet_source) - - -SnippetExecutionRequestMeta = Annotated[ - SnippetExecutionMeta, Depends(get_snippet_execution_request_meta) -] - - -def get_executable_snippet_for_api(snippet: SnippetDep) -> Snippet: - """Return the snippet if it can be executed, otherwise raise 403. - - JSON API analogue of :func:`get_executable_snippet`. Where the form - flow flashes a message and redirects to the snippet's detail page, - the API surfaces a structured 403 the FE can render inline. - - :param snippet: The snippet to verify. - :type snippet: Snippet - :return: The same snippet when it is executable. - :rtype: Snippet - :raises HTTPForbiddenException: If the snippet cannot be executed - (for example, it is unapproved or has no resolvable - interpreter). - """ - if snippet.can_execute: - return snippet - if not snippet.is_approved: - raise HTTPForbiddenException( - detail=f"Snippet {snippet.filename!r} is not approved.", - ) - raise HTTPForbiddenException( - detail=f"Snippet {snippet.filename!r} cannot be executed.", - ) - - -ExecutableSnippetForApi = Annotated[Snippet, Depends(get_executable_snippet_for_api)] - - def require_manual_sync_enabled() -> None: """Raise HTTPForbiddenException if manual snippet sync is disabled. @@ -453,10 +183,6 @@ def require_manual_sync_enabled() -> None: IsManualSyncEnabled = Depends(require_manual_sync_enabled) -class SnippetBatchApproveForm(SnippetBatchApproveRequest): - """Form-bound twin used by the legacy Jinja2 batch-approve route.""" - - @dataclass(slots=True) class SnippetBatchExistenceResult: """Structured outcome of the batch-approve precheck. diff --git a/app/sep/snippets/forms.py b/app/sep/snippets/forms.py deleted file mode 100644 index 2d67057d37..0000000000 --- a/app/sep/snippets/forms.py +++ /dev/null @@ -1,1059 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define HTML elements and form components using Pydantic models.""" - -__all__ = [ - "CheckboxInputElement", - "DateTimeInputElement", - "FieldsetElement", - "FormElement", - "FormFieldElement", - "FormMethod", - "InputType", - "NumberInputElement", - "OptionElement", - "SelectElement", - "SpanElement", - "SubmitButtonElement", - "TextContent", - "TextInputElement", - "TextInputHTMLElement", - "TextareaElement", -] - -from abc import ABC, abstractmethod -from collections.abc import Hashable -from enum import auto, StrEnum -from functools import cached_property -from html import escape as html_escape -from typing import Annotated, Any, ClassVar, Self - -from pydantic import ( - AliasChoices, - BaseModel, - computed_field, - ConfigDict, - Field, - model_validator, - PositiveInt, - validate_call, -) - -from app.core.utils import ttl_cache -from app.core.utils.fields import EnumFieldMixin, LenientStr, NonEmptyStr - -HTMLClassName = Annotated[str, Field(pattern=r"^-?[_a-zA-Z]+[_a-zA-Z0-9-]*$")] -ICON_CLASS = "material-symbols-outlined" -_TWENTY_FOUR_HOURS = 24 * 60 * 60 - - -class TextInputHTMLElement(EnumFieldMixin, StrEnum): - """Enumerate the types of HTML text input elements.""" - - INPUT = auto() - TEXTAREA = auto() - - -class InputType(StrEnum): - """Enumerate the types of HTML input elements.""" - - TEXT = "text" - NUMBER = "number" - CHECKBOX = "checkbox" - DATETIME_LOCAL = "datetime-local" - - -class FormMethod(StrEnum): - """Enumerate the possible methods used in HTML forms.""" - - GET = "get" - POST = "post" - DIALOG = "dialog" - - -class CreateFromStrMixin(BaseModel): - """Mixin to create an instance from a string. - - This mixin provides a validator that allows creating an instance of the model - from a simple string input. The string is assigned to a specific field defined by - the `POPULATE_FIELDS_FROM_STR` class variable. - - :cvar POPULATE_FIELDS_FROM_STR: The field name to which the string input will be assigned. - :vartype: ClassVar[tuple[str, ...]] - """ - - POPULATE_FIELDS_FROM_STR: ClassVar[tuple[str, ...]] = () - - @model_validator(mode="before") - @classmethod - def create_from_str(cls, data: Any) -> Any: - """Create an instance from a string. - - Converts a string into an instance of the model by assigning the string to - specific fields defined by `POPULATE_FIELDS_FROM_STR`. This allows for easy - instantiation of models from simple string inputs. - - :param data: The input data. - :type data: Any - :return: The validated data. - :rtype: Any - """ - if isinstance(data, str) and cls.POPULATE_FIELDS_FROM_STR: - return {field_name: data for field_name in cls.POPULATE_FIELDS_FROM_STR} - return data - - -class FormFieldMixin(BaseModel): - """Mixin for form fields to provide common attributes and methods. - - This mixin provides common attributes and methods for form fields, including - handling of required fields, labels, and HTML conversion. It is intended to be - used as a base class for various form field types, such as text inputs, checkboxes, - and selects. - - :param: name: The name attribute of the form field. - :type: name: NonEmptyStr - :param: required: Whether the field is required. Defaults to False. - :type: required: bool - :param: label: The label for the form field, if any. Defaults to None. - :type: label: NonEmptyStr | None - :param: description: The tooltip description rendered as an info icon next to the - field. Defaults to None. - :type: description: NonEmptyStr | None - """ - - name: NonEmptyStr - required: bool = False - label: NonEmptyStr | None = Field(None, exclude=True) - description: NonEmptyStr | None = Field(None, exclude=True) - - @model_validator(mode="after") - def set_label_by_name(self) -> Self: - """Set the label to the name if not provided. - - If the label is not explicitly set, this method assigns the value of the - `name` attribute to the `label` attribute. - - :return: The instance with the label set. - :rtype: Self - """ - if self.label is None: - self.label = self.name - return self - - def to_html(self) -> str: - """Return the HTML representation of the form field. - - Generate the HTML for the form field, wrapping it in a label if a label is - provided. When a description is set, render an info-icon tooltip span adjacent - to the label text, before the field HTML. - - :return: The HTML representation of the form field, possibly wrapped in a label. - :rtype: str - """ - html = super().to_html() - info_icon = "" - if self.description: - desc = html_escape(self.description) - info_icon = ( - f'' - f'info' - ) - if self.label: - label_text = html_escape(self.label) - if info_icon: - label_text = f"{label_text}{info_icon}" - return f"" - return html + info_icon - - -class TypeableFieldMixin(FormFieldMixin): - """Define mixin for form fields that can have a placeholder attribute. - - :param: name: The name attribute of the form field. - :type: name: NonEmptyStr - :param: required: Whether the field is required. Defaults to False - :type: required: bool - :param: label: The label for the form field, if any. Defaults to None - :type: label: NonEmptyStr | None - :param: placeholder: The placeholder text for the form field, if any. Defaults to - None. - :type: placeholder: NonEmptyStr | None - :param: value: The default value of the form field, if any. Defaults to None. - :type: value: str | None - """ - - placeholder: NonEmptyStr | None = None - value: LenientStr | None = Field(None, validation_alias="default") - - -class TextFieldMixin(TypeableFieldMixin): - """Define mixin for text form fields with length validation. - - :param: name: The name attribute of the form field. - :type: name: NonEmptyStr - :param: required: Whether the field is required. Defaults to False - :type: required: bool - :param: label: The label for the form field, if any. Defaults to None - :type: label: NonEmptyStr | None - :param: placeholder: The placeholder text for the form field, if any. Defaults to - None. - :type: placeholder: NonEmptyStr | None - :param: value: The default value of the form field, if any. Defaults to None. - :type: value: str | None - :param: maxlength: The maximum length of the text input. If None, no maximum - length is enforced. Defaults to None. - :type: maxlength: PositiveInt | None - :param: minlength: The minimum length of the text input. If None, no minimum - length is enforced. Defaults to None. - :type: minlength: PositiveInt | None - """ - - maxlength: PositiveInt | None = Field( - None, validation_alias=AliasChoices("maxlength", "max_length") - ) - minlength: PositiveInt | None = Field( - None, validation_alias=AliasChoices("minlength", "min_length") - ) - - -class SetChildrenByLabelMixin(CreateFromStrMixin, BaseModel): - """Mixin to set children based on a label. - - This mixin is used to automatically create a child TextContent element - with the label text after the model is validated. It is useful for elements - that need to display a label as their content, such as buttons or span elements. - - :cvar: POPULATE_FIELDS_FROM_STR: The field names to which the string input will be - assigned (`("label",)`). - :vartype: POPULATE_FIELDS_FROM_STR: ClassVar[tuple[str, ...]] - :param: label: The label text to be used for the child TextContent element. - :type: label: NonEmptyStr - """ - - POPULATE_FIELDS_FROM_STR: ClassVar[tuple[str, ...]] = ("label",) - label: NonEmptyStr = Field(exclude=True) - - @model_validator(mode="after") - def _set_children_by_label(self) -> Self: - self.children = [TextContent(text=self.label)] - return self - - -class BaseHTMLEntity(BaseModel, ABC): - """Define base class for HTML entities. - - This class defines the interface for HTML entities that can be converted to HTML. - It requires subclasses to implement the `to_html` method, which should return - the HTML representation of the entity. - """ - - model_config = ConfigDict(populate_by_name=True) - - @abstractmethod - def to_html(self) -> str: - """Return HTML representation of entity. - - :return: The HTML representation of the entity as a string. - :rtype: str - """ - - -class BaseHTMLElement(BaseHTMLEntity, ABC): - """Define base class for HTML elements. - - This class provides common attributes and methods for HTML elements, including - handling of tag names, classes, IDs, and HTML attributes. It also provides - methods for generating start and end tags, as well as converting the element - to HTML. - - :cvar: TAG_NAME: The name of the HTML tag (e.g., 'div', 'span'). - :vartype: TAG_NAME: ClassVar[str] - :param: classes: A set of CSS class names associated with the element. - :type: classes: set[HTMLClassName] - :param: id: The ID attribute of the element. - :type: id: NonEmptyStr | None - """ - - TAG_NAME: ClassVar[str] - classes: set[HTMLClassName] = Field(default_factory=set, exclude=True) - id: NonEmptyStr | None = Field(None, pattern=r"^\S+$") - - @computed_field(alias="class") - @property - def _class(self) -> str | None: - """Return the class attribute as a space-separated string of class names. - - :return: A space-separated string of class names or None if no classes are set. - :rtype: str | None - """ - return " ".join(self.classes) or None - - @property - def attributes(self) -> str: - """Return a string of HTML attributes from the model fields. - - :return: A string of HTML attributes. - :rtype: str - """ - return self._get_attributes_str( - sorted(self.model_dump(by_alias=True, exclude_none=True).items()) - ) - - @property - def start_tag(self) -> str: - """Return the opening tag for the HTML element. - - :return: The opening tag as a string. - :rtype: str - """ - tag = f"<{self.TAG_NAME}" - attrs = self.attributes - if attrs: - tag += f" {attrs}" - tag += ">" - return tag - - @property - def prepend_raw(self) -> str: - """Return any raw HTML to prepend before the start tag. - - This can be overridden in subclasses to add custom HTML before the element's - start tag. - - :return: An empty string by default. - :rtype: str - """ - return "" - - @property - def append_raw(self) -> str: - """Return any raw HTML to append after the end tag. - - This can be overridden in subclasses to add custom HTML after the element's - end tag. - - :return: An empty string by default. - :rtype: str - """ - return "" - - @staticmethod - @validate_call - @ttl_cache(ttl=_TWENTY_FOUR_HOURS, maxsize=32) - def _get_attributes_str(attributes: tuple[tuple[str, Hashable], ...]) -> str: - """Convert a list of attribute-value pairs into a string of HTML attributes. - - :param attributes: A tuple of (attribute, value) pairs. - :type attributes: tuple[tuple[str, Hashable], ...] - :return: A string of HTML attributes. - :rtype: str - """ - attrs = [] - for attr, value in attributes: - if isinstance(value, bool): - if value: - attrs.append(attr) - else: - attrs.append(f'{attr}="{html_escape(str(value))}"') - return " ".join(attrs) - - -class VoidHTMLElement(BaseHTMLElement, ABC): - """Define base class for void HTML elements (self-closing tags). - - This class provides the interface for HTML elements that do not have closing tags, - such as ``. - - :cvar: TAG_NAME: The name of the HTML tag (e.g., 'div', 'span'). - :vartype: TAG_NAME: ClassVar[str] - :param: classes: A set of CSS class names associated with the element. - :type: classes: set[HTMLClassName] - :param: id: The ID attribute of the element. - :type: id: NonEmptyStr | None - """ - - def to_html(self) -> str: - """Return the HTML representation of the void element. - - :return: The HTML representation of the void element. - :rtype: str - """ - return self.prepend_raw + self.start_tag + self.append_raw - - -class HTMLElement(BaseHTMLElement, ABC): - """Define base class for HTML elements that can have children. - - This class provides methods for handling child elements and generating the full - HTML representation of the element, including its children. - - :cvar: TAG_NAME: The name of the HTML tag (e.g., 'div', 'span'). - :vartype: TAG_NAME: ClassVar[str] - :param: classes: A set of CSS class names associated with the element. - :type: classes: set[HTMLClassName] - :param: id: The ID attribute of the element. - :type: id: NonEmptyStr | None - :param: children: A list of child HTML entities contained within this element. - :type: children: list[BaseHTMLEntity] - """ - - children: list[BaseHTMLEntity] = Field(default_factory=list, exclude=True) - - @cached_property - def end_tag(self) -> str: - """Return the closing tag for the HTML element. - - :return: The closing tag as a string. - :rtype: str - """ - return f"" - - @property - def inner_html(self) -> str: - """Return the inner HTML content of the element, including its children. - - This property generates the HTML for all child elements contained within this - element by calling their `to_html` methods and concatenating the results. - - :return: The inner HTML content as a string. - :rtype: str - """ - return "".join(elem.to_html() for elem in self.children) - - def to_html(self) -> str: - """Return the full HTML representation of the element. - - This method combines the start tag, inner HTML, and end tag, along with any - raw HTML to prepend or append, to produce the complete HTML for the element. - - :return: The full HTML representation of the element. - :rtype: str - """ - return ( - self.prepend_raw - + self.start_tag - + self.inner_html - + self.end_tag - + self.append_raw - ) - - -class TextContent(CreateFromStrMixin, BaseHTMLEntity): - """Define a simple text content entity. - - This class represents plain text content that can be included within HTML elements. - - :cvar: POPULATE_FIELDS_FROM_STR: The field names to which the string input will be - assigned (`("text",)`). - :vartype: POPULATE_FIELDS_FROM_STR: ClassVar[tuple[str, ...]] - :param: text: The text content to be rendered. - :type: text: str - """ - - POPULATE_FIELDS_FROM_STR: ClassVar[tuple[str, ...]] = ("text",) - text: str - - def to_html(self) -> str: - """Return the HTML representation of the text content. - - This method escapes the text content to ensure it is safe for inclusion in HTML, - preventing issues such as XSS attacks or HTML injection. - - :return: The HTML representation of the text content. - :rtype: str - """ - return html_escape(self.text) - - -class FieldsetElement(HTMLElement): - """Define a fieldset HTML element with an optional legend. - - This class represents a `
` element, which can contain a legend and other - HTML elements. The legend is typically used to provide a caption for the fieldset. - - :cvar: TAG_NAME: The name of the HTML tag ('fieldset'). - :vartype: TAG_NAME: ClassVar[str] - :param: classes: A set of CSS class names associated with the element. - :type: classes: set[HTMLClassName] - :param: id: The ID attribute of the element. - :type: id: NonEmptyStr | None - :param: children: A list of child HTML entities contained within this element. - :type: children: list[BaseHTMLEntity] - :param: legend: The text for the legend of the fieldset, if any. Defaults to None. - :type: legend: NonEmptyStr | None - :param: disabled: Whether the fieldset is disabled. Defaults to False. - :type: disabled: bool - """ - - TAG_NAME: ClassVar[str] = "fieldset" - legend: NonEmptyStr | None = Field(None, exclude=True) - disabled: bool = False - - @property - def inner_html(self) -> str: - """Return the inner HTML content of the fieldset, including the legend if set. - - This property generates the HTML for the legend (if provided) and concatenates - it with the inner HTML of the fieldset's children. - - :return: The inner HTML content as a string, including the legend if present. - :rtype: str - """ - html = super().inner_html - if self.legend: - html = f"{html_escape(self.legend)}{html}" - return html - - -class SpanElement(SetChildrenByLabelMixin, HTMLElement): - """Define a span HTML element. - - :cvar: TAG_NAME: The name of the HTML tag ('span'). - :vartype: TAG_NAME: ClassVar[str] - :param: label: The label text to be used for the child TextContent element. - :type: label: NonEmptyStr - :param: classes: A set of CSS class names associated with the element. - :type: classes: set[HTMLClassName] - :param: id: The ID attribute of the element. - :type: id: NonEmptyStr | None - :param: children: A list of child HTML entities contained within this element. - :type: children: list[BaseHTMLEntity] - """ - - TAG_NAME: ClassVar[str] = "span" - - -class TextareaElement(TextFieldMixin, HTMLElement): - """Define a textarea HTML element with optional rows and columns. - - :cvar: TAG_NAME: The name of the HTML tag ('textarea'). - :vartype: TAG_NAME: ClassVar[str] - :param: name: The name attribute of the form field. - :type: name: NonEmptyStr - :param: required: Whether the field is required. Defaults to False - :type: required: bool - :param: label: The label for the form field, if any. Defaults to None - :type: label: NonEmptyStr | None - :param: placeholder: The placeholder text for the form field, if any. Defaults to - None. - :type: placeholder: NonEmptyStr | None - :param: value: The default value of the form field, if any. Defaults to None. - :type: value: str | None - :param: maxlength: The maximum length of the text input. If None, no maximum - length is enforced. Defaults to None. - :type: maxlength: PositiveInt | None - :param: minlength: The minimum length of the text input. If None, no minimum - length is enforced. Defaults to None. - :type: minlength: PositiveInt | None - :param: classes: A set of CSS class names associated with the element. - :type: classes: set[HTMLClassName] - :param: id: The ID attribute of the element. - :type: id: NonEmptyStr | None - :param: description: The tooltip description rendered as an info icon next to the - field. Defaults to None. - :type: description: NonEmptyStr | None - :param: children: A list of child HTML entities contained within this element. - :type: children: list[BaseHTMLEntity] - :param: rows: The number of rows in the textarea. If None, no specific - number of rows is set. Defaults to None. - :type: rows: PositiveInt | None - :param: cols: The number of columns in the textarea. If None, no specific - number of columns is set. Defaults to None. - :type: cols: PositiveInt | None - """ - - TAG_NAME: ClassVar[str] = "textarea" - rows: PositiveInt | None = None - cols: PositiveInt | None = None - - -class OptionElement(SetChildrenByLabelMixin, HTMLElement): - """Define an option HTML element. - - :cvar: POPULATE_FIELDS_FROM_STR: The field names to which the string input will be - assigned (`["value", "label"]`). - :vartype: POPULATE_FIELDS_FROM_STR: ClassVar[tuple[str, ...]] - :cvar: TAG_NAME: The name of the HTML tag ('option'). - :vartype: TAG_NAME: ClassVar[str] - :param: label: The label text to be used for the child TextContent element. - :type: label: NonEmptyStr - :param: classes: A set of CSS class names associated with the element. - :type: classes: set[HTMLClassName] - :param: id: The ID attribute of the element. - :type: id: NonEmptyStr | None - :param: children: A list of child HTML entities contained within this element. - :type: children: list[BaseHTMLEntity] - :param: value: The value of the option element, which is submitted with the form. - :type: value: str - :param: selected: Whether the option is selected by default. Defaults to False. - :type: selected: bool - """ - - POPULATE_FIELDS_FROM_STR: ClassVar[tuple[str, ...]] = ("value", "label") - TAG_NAME: ClassVar[str] = "option" - value: str - selected: bool = False - - @model_validator(mode="before") - @classmethod - def set_default_label(cls, data: Any) -> Any: - """Set the label for the option element if not provided. - - If the input data is a dictionary and contains a "value" key, it sets the - "label" key to the value of "value" if "label" is not already set. - - :param data: The input data. - :type data: Any - :return: The validated data with the label set. - :rtype: Any - """ - if isinstance(data, dict) and "value" in data: - data["label"] = data.get("label", data["value"]) - return data - - -class SelectElement(FormFieldMixin, HTMLElement): - """Define a select HTML element with options. - - :cvar: TAG_NAME: The name of the HTML tag ('select'). - :vartype: TAG_NAME: ClassVar[str] - :param: name: The name attribute of the form field. - :type: name: NonEmptyStr - :param: required: Whether the field is required. Defaults to False. - :type: required: bool - :param: label: The label for the form field, if any. Defaults to None. - :type: label: NonEmptyStr | None - :param: classes: A set of CSS class names associated with the element. - :type: classes: set[HTMLClassName] - :param: id: The ID attribute of the element. - :type: id: NonEmptyStr | None - :param: description: The tooltip description rendered as an info icon next to the - field. Defaults to None. - :type: description: NonEmptyStr | None - :param: children: A list of child options contained within this element. - :type: children: list[OptionElement] - :param: default: The default selected option value. If None (default), no option is - selected by default, but a placeholder option may be added if there are multiple - options. - :type: default: str | None - """ - - TAG_NAME: ClassVar[str] = "select" - children: list[OptionElement] = Field( - default_factory=list, - exclude=True, - validation_alias=AliasChoices("choices", "options"), - ) - default: str | None = Field(None, exclude=True) - - @model_validator(mode="after") - def set_default_selection(self) -> Self: - """Set the default selected option based on the `default` attribute. - - :return: The updated instance with the default selection set. - :rtype: SelectElement - """ - if self.default is not None: - for option in self.children: - if option.value == self.default: - option.selected = True - return self - if len(self.children) > 1: - self.children.insert(0, OptionElement(value="", label="Select an option")) - return self - - @property - def prepend_raw(self) -> str: - """Return raw HTML to prepend before the select element. - - This property returns an icon indicating that the select element can be expanded. - - :return: An HTML span element with an icon for dropdown indication. - :rtype: str - """ - return f'arrow_drop_down' - - -class BaseInputElement(FormFieldMixin, VoidHTMLElement, ABC): - """Define base class for the input HTML element. - - :cvar: TAG_NAME: The name of the HTML tag ('input'). - :vartype: TAG_NAME: ClassVar[str] - :param: name: The name attribute of the form field. - :type: name: NonEmptyStr - :param: required: Whether the field is required. Defaults to False. - :type: required: bool - :param: label: The label for the form field, if any. Defaults to None. - :type: label: NonEmptyStr | None - :param: classes: A set of CSS class names associated with the element. - :type: classes: set[HTMLClassName] - :param: id: The ID attribute of the element. - :type: id: NonEmptyStr | None - :param: description: The tooltip description rendered as an info icon next to the - field. Defaults to None. - :type: description: NonEmptyStr | None - """ - - TAG_NAME: ClassVar[str] = "input" - - @computed_field - @property - @abstractmethod - def type(self) -> InputType: - """Return the type of the input element. - - This property must be implemented by subclasses to specify the input type - (e.g., text, number, checkbox). - - :return: The type of the input element. - :rtype: InputType - """ - - -class TextInputElement(TextFieldMixin, BaseInputElement): - """Define a text input HTML element. - - :cvar: TAG_NAME: The name of the HTML tag ('input'). - :vartype: TAG_NAME: ClassVar[str] - :param: name: The name attribute of the form field. - :type: name: NonEmptyStr - :param: required: Whether the field is required. Defaults to False - :type: required: bool - :param: label: The label for the form field, if any. Defaults to None - :type: label: NonEmptyStr | None - :param: placeholder: The placeholder text for the form field, if any. Defaults to - None. - :type: placeholder: NonEmptyStr | None - :param: value: The default value of the form field, if any. Defaults to None. - :type: value: str | None - :param: maxlength: The maximum length of the text input. If None, no maximum - length is enforced. Defaults to None. - :type: maxlength: PositiveInt | None - :param: minlength: The minimum length of the text input. If None, no minimum - length is enforced. Defaults to None. - :type: minlength: PositiveInt | None - :param: classes: A set of CSS class names associated with the element. - :type: classes: set[HTMLClassName] - :param: id: The ID attribute of the element. - :type: id: NonEmptyStr | None - :param: description: The tooltip description rendered as an info icon next to the - field. Defaults to None. - :type: description: NonEmptyStr | None - :param: pattern: A regex pattern that the input value must match. If None, no - pattern is enforced. Defaults to None. - :type: pattern: NonEmptyStr | None - """ - - pattern: NonEmptyStr | None = None - - @computed_field - @property - def type(self) -> InputType: - """Return the type of the input element. - - :return: The type of the input element (`InputType.TEXT`). - :rtype: InputType - """ - return InputType.TEXT - - -class DateTimeInputElement(TextFieldMixin, BaseInputElement): - """Define a datetime-local input HTML element. - - :cvar: TAG_NAME: The name of the HTML tag ('input'). - :vartype: TAG_NAME: ClassVar[str] - :param: name: The name attribute of the form field. - :type: name: NonEmptyStr - :param: required: Whether the field is required. Defaults to False - :type: required: bool - :param: label: The label for the form field, if any. Defaults to None - :type: label: NonEmptyStr | None - :param: placeholder: The placeholder text for the form field, if any. Defaults to - None. - :type: placeholder: NonEmptyStr | None - :param: value: The default value of the form field, if any. Defaults to None. - :type: value: str | None - :param: classes: A set of CSS class names associated with the element. - :type: classes: set[HTMLClassName] - :param: id: The ID attribute of the element. - :type: id: NonEmptyStr | None - :param: description: The tooltip description rendered as an info icon next to the - field. Defaults to None. - :type: description: NonEmptyStr | None - """ - - @computed_field - @property - def type(self) -> InputType: - """Return the type of the input element. - - :return: The type of the input element (`InputType.DATETIME_LOCAL`). - :rtype: InputType - """ - return InputType.DATETIME_LOCAL - - -class NumberInputElement(TypeableFieldMixin, BaseInputElement): - """Define a number input HTML element. - - :cvar: TAG_NAME: The name of the HTML tag ('input'). - :vartype: TAG_NAME: ClassVar[str] - :param: name: The name attribute of the form field. - :type: name: NonEmptyStr - :param: required: Whether the field is required. Defaults to False - :type: required: bool - :param: label: The label for the form field, if any. Defaults to None - :type: label: NonEmptyStr | None - :param: placeholder: The placeholder text for the form field, if any. Defaults to - None. - :type: placeholder: NonEmptyStr | None - :param: value: The default value of the form field, if any. Defaults to None. - :type: value: str | None - :param: classes: A set of CSS class names associated with the element. - :type: classes: set[HTMLClassName] - :param: id: The ID attribute of the element. - :type: id: NonEmptyStr | None - :param: description: The tooltip description rendered as an info icon next to the - field. Defaults to None. - :type: description: NonEmptyStr | None - :param: max: The maximum value allowed for the number input. If None, no maximum - is enforced. Defaults to None. - :type: max: float | None - :param: min: The minimum value allowed for the number input. If None, no - minimum is enforced. Defaults to None. - :type: min: float | None - :param: step: The step value for the number input, defining the intervals at which - the value can change. If None, no specific step is enforced. Defaults to None. - """ - - max: float | None = Field(None, validation_alias=AliasChoices("max", "le")) - min: float | None = Field(None, validation_alias=AliasChoices("min", "ge")) - step: float | None = None - - @model_validator(mode="before") - @classmethod - def get_max_min_from_lt_gt(cls, data: Any) -> Any: - """Set 'le' and 'ge' from 'lt' and 'gt' if 'step' is provided. - - If the input data is a dictionary and contains a "step" key, it sets the "le" - (less than or equal) and "ge" (greater than or equal) keys based - on the "lt" (less than) and "gt" (greater than) keys, respectively, if they - weren't directly provided. - - :param data: The input data, which can be a dictionary or other types. - :type data: Any - :return: The validated data with "le" and "ge" set if applicable. - :rtype: Any - """ - if isinstance(data, dict): - step = data.get("step") - if step is not None: - if "le" not in data and (lt := data.get("lt")) is not None: - data["le"] = lt - step - if "ge" not in data and (gt := data.get("gt")) is not None: - data["ge"] = gt + step - return data - - @computed_field - @property - def type(self) -> InputType: - """Return the type of the input element. - - :return: The type of the input element (`InputType.NUMBER`). - :rtype: InputType - """ - return InputType.NUMBER - - -class CheckboxInputElement(BaseInputElement): - """Define a checkbox input HTML element. - - :cvar: TAG_NAME: The name of the HTML tag ('input'). - :vartype: TAG_NAME: ClassVar[str] - :param: name: The name attribute of the form field. - :type: name: NonEmptyStr - :param: required: Whether the field is required. Defaults to False. - :type: required: bool - :param: label: The label for the form field, if any. Defaults to None. - :type: label: NonEmptyStr | None - :param: classes: A set of CSS class names associated with the element. - :type: classes: set[HTMLClassName] - :param: id: The ID attribute of the element. - :type: id: NonEmptyStr | None - :param: description: The tooltip description rendered as an info icon next to the - field. Defaults to None. - :type: description: NonEmptyStr | None - :param: checked: Whether the checkbox is checked by default. Defaults to False. - :type: checked: bool - """ - - checked: bool = Field(default=False, validation_alias="default") - - @computed_field - @property - def type(self) -> InputType: - """Return the type of the input element. - - :return: The type of the input element (`InputType.CHECKBOX`). - :rtype: InputType - """ - return InputType.CHECKBOX - - @property - def prepend_raw(self) -> str: - """Return raw HTML to prepend before the checkbox input element. - - This property returns a hidden input element with the same name as the checkbox. - This is done to ensure that a value is always submitted for the checkbox, even - when it is unchecked (since unchecked checkboxes do not send any value). - - :return: A hidden HTML input element with the same name as the checkbox and - falsy value. - :rtype: str - """ - return f'' - - @property - def append_raw(self) -> str: - """Return raw HTML to append after the checkbox input element. - - This property returns HTML for a checkmark icon that visually represents the - checkbox state. - - :return: An HTML div element with checkmark icons. - :rtype: str - """ - return ( - '
' - f'check_box_outline_blank' - f'check_box' - "
" - ) - - -class SubmitButtonElement(HTMLElement): - """Define a submit button HTML element with optional icon. - - :cvar: TAG_NAME: The name of the HTML tag ('button'). - :vartype: TAG_NAME: ClassVar[str] - :param: classes: A set of CSS class names associated with the element. - :type: classes: set[HTMLClassName] - :param: id: The ID attribute of the element. - :type: id: NonEmptyStr | None - :param: children: A list of child HTML entities contained within this element. - :type: children: list[BaseHTMLEntity] - :param: label: The label text for the button. - :type: label: NonEmptyStr - :param: icon: The name of the icon to display on the button, if any. Defaults to - None. - :type: icon: NonEmptyStr | None - :param: disabled: Whether the button is disabled. Defaults to False. - :type: disabled: bool - """ - - TAG_NAME: ClassVar[str] = "button" - label: NonEmptyStr = Field(exclude=True, validation_alias="text") - icon: NonEmptyStr | None = Field(None, exclude=True) - disabled: bool = False - - @model_validator(mode="before") - @classmethod - def create_from_str(cls, data: Any) -> Any: - """Create a SubmitButtonElement instance from a string. - - Converts a string into a `SubmitButtonElement` instance, allowing it to be used - as a button with the string as its label. - - :param data: The input data. - :type data: Any - :return: The validated data. - :rtype: Any - """ - if isinstance(data, str): - return {"label": data} - return data - - @model_validator(mode="before") - @classmethod - def _set_children_by_label(cls, data: Any) -> Any: - if isinstance(data, str): - data = {"label": data} - if isinstance(data, dict) and "label" in data: - if (icon := data.get("icon")) is None: - data["children"] = (TextContent(text=data["label"]),) - else: - data["children"] = ( - SpanElement(label=icon, classes=(ICON_CLASS,)), - SpanElement(label=data["label"]), - ) - return data - - @computed_field - @property - def type(self) -> str: - """Return the type of the button element ('submit'). - - :return: The type of the button element ('submit'). - :rtype: str - """ - return "submit" - - -class FormElement(HTMLElement): - """Define a form HTML element with a submit button. - - :cvar: TAG_NAME: The name of the HTML tag ('form'). - :vartype: TAG_NAME: ClassVar[str] - :param: classes: A set of CSS class names associated with the element. - :type: classes: set[HTMLClassName] - :param: id: The ID attribute of the element. - :type: id: NonEmptyStr | None - :param: children: A list of child HTML entities contained within this element. - :type: children: list[BaseHTMLEntity] - :param: action: The URL to which the form data will be submitted. Defaults to an - empty string, meaning the form will submit to the current page. - :type: action: str - :param: method: The HTTP method for the form submission. Defaults to - :attr:`FormMethod.POST`. - :type: FormMethod - :param: submit_button: The submit button element to be included in the form. - :type: submit_button: SubmitButtonElement - """ - - TAG_NAME: ClassVar[str] = "form" - action: str = "" - method: FormMethod = FormMethod.POST - submit_button: SubmitButtonElement = Field( - SubmitButtonElement(label="send", classes=(ICON_CLASS, "text")), - exclude=True, - ) - - @property - def inner_html(self) -> str: - """Return the inner HTML content of the form. - - This property generates the HTML for all child elements contained within the - form and appends the submit button's HTML. - - :return: The inner HTML content as a string, including the submit button. - :rtype: str - """ - return super().inner_html + self.submit_button.to_html() - - -FormFieldElement = BaseInputElement | SelectElement | TextareaElement diff --git a/app/sep/snippets/models/constants.py b/app/sep/snippets/models/constants.py index 346210bdbf..df4f2074b3 100644 --- a/app/sep/snippets/models/constants.py +++ b/app/sep/snippets/models/constants.py @@ -15,6 +15,18 @@ """Define wire-name constants shared across snippet and framework schema modules.""" +from enum import StrEnum + +from app.core.utils.fields import EnumFieldMixin + + +class TextInputHTMLElement(EnumFieldMixin, StrEnum): + """Enumerate the types of HTML text input elements.""" + + INPUT = "input" + TEXTAREA = "textarea" + + EXTRA_ARGS_FIELD_NAME = "extra_args" """Name the synthesized Extra Args execution field on the wire. diff --git a/app/sep/snippets/models/meta.py b/app/sep/snippets/models/meta.py index 56d14412a0..c23eaa4755 100644 --- a/app/sep/snippets/models/meta.py +++ b/app/sep/snippets/models/meta.py @@ -67,17 +67,10 @@ field_with_metadata, loc_to_dot_sep, ) -from app.sep.snippets.forms import ( - CheckboxInputElement, - DateTimeInputElement, - FormFieldElement, - NumberInputElement, - SelectElement, - TextareaElement, - TextInputElement, +from app.sep.snippets.models.constants import ( + EXTRA_ARGS_FIELD_NAME, TextInputHTMLElement, ) -from app.sep.snippets.models.constants import EXTRA_ARGS_FIELD_NAME ParameterType = str | int | float | bool | datetime | None @@ -585,28 +578,6 @@ def is_flag(self) -> bool: """ return self.py_type == SnippetMetaParameterType.BOOL - @cached_property - def form_field_element_cls(self) -> type[FormFieldElement]: - """Get the form field element class based on the parameter type. - - :return: The appropriate form field element class for the parameter type. - :rtype: type[FormFieldElement] - """ - if self.choices: - return SelectElement - if self.py_type == SnippetMetaParameterType.BOOL: - return CheckboxInputElement - if self.py_type in [ - SnippetMetaParameterType.INT, - SnippetMetaParameterType.FLOAT, - ]: - return NumberInputElement - if self.py_type == SnippetMetaParameterType.DATETIME: - return DateTimeInputElement - if self.html_elem == TextInputHTMLElement.TEXTAREA: - return TextareaElement - return TextInputElement - @cached_property def constraints(self) -> list[GroupedMetadata]: """Get the constraints for the parameter based on its type and attributes. @@ -679,16 +650,6 @@ def to_validation_field(self) -> FieldInfo: ), ) - def to_form_field(self) -> FormFieldElement: - """Convert the SnippetMetaParameter to a form field element. - - :return: An instance of the form field element class corresponding to the - parameter type, populated with the parameter's attributes. - :rtype: FormFieldElement - """ - instance_data = self.model_dump(exclude_none=True) - return self.form_field_element_cls.model_validate(instance_data) - @staticmethod def convert_validation_errors(exc: ValidationError, input_param: Any) -> list[str]: """Convert Pydantic ValidationError to a list of readable error messages. diff --git a/app/sep/snippets/models/snippet.py b/app/sep/snippets/models/snippet.py index ae3b8dac87..919d66aa6e 100644 --- a/app/sep/snippets/models/snippet.py +++ b/app/sep/snippets/models/snippet.py @@ -22,7 +22,6 @@ import json import logging import shlex -from collections.abc import Iterable from functools import cached_property from io import SEEK_END from os import PathLike @@ -65,7 +64,6 @@ UTCDatetime, ) from app.core.utils.pydantic import CustomFieldMetadata -from app.sep.apps.labels import EXECUTION_HOST_LABEL from app.sep.snippets.config import ( DEFAULT_SNIPPETS_TASK, SnippetFilterType, @@ -76,14 +74,6 @@ if TYPE_CHECKING: from aiofiles.threadpool.text import AsyncTextIOWrapper -from app.sep.snippets.forms import ( - CheckboxInputElement, - FieldsetElement, - FormElement, - SelectElement, - SubmitButtonElement, - TextInputElement, -) from app.sep.snippets.models.constants import EXTRA_ARGS_FIELD_NAME from app.sep.snippets.models.meta import ( META_KEY_DESCRIPTION, @@ -99,55 +89,13 @@ _SEVEN_DAYS = 7 * 24 * _ONE_HOUR EXECUTOR_HOSTS_INPUT_NAME = "-hostname-" EXTRA_ARGS_INPUT_NAME = "-extra_args-" -EXTRA_ARGS_INPUT = TextInputElement( - name=EXTRA_ARGS_INPUT_NAME, - placeholder="e.g. --verbose", - description="Any extra args to pass to the snippet execution command", - label="Extra Args", -) SUDO_INPUT_NAME = "-sudo-" -SUDO_INPUT = CheckboxInputElement( - name=SUDO_INPUT_NAME, - description="Execute the snippet with sudo", - label="Use sudo", - id="sudoCheckbox", -) logger = logging.getLogger(__name__) ExtraArgsField = Annotated[list[str], BeforeValidator(shlex.split)] -@validate_call -@ttl_cache(ttl=_SEVEN_DAYS, maxsize=4) -def get_executor_hosts_fieldset( - executor_hosts: frozenset[tuple[str, str]], -) -> FieldsetElement: - """Create a fieldset for executor hosts with a legend and input fields. - - :param executor_hosts: A set of (value, label) tuples for executor hosts. - :type executor_hosts: frozenset[tuple[str, str]] - :return: A FieldsetElement containing the input fields for the executor hosts. - :rtype: FieldsetElement - """ - options = [ - {"value": value, "label": label} for value, label in sorted(executor_hosts) - ] - return FieldsetElement( - legend=EXECUTION_HOST_LABEL, - children=[ - SelectElement( - children=options, - name=EXECUTOR_HOSTS_INPUT_NAME, - description="Select the hostname of the target system for snippet execution.", - label="Select host", - required=True, - classes={"executorHostSelect"}, - ), - ], - ) - - class FilePreview(NamedTuple): """Represent a preview of a snippet's content with separated frontmatter. @@ -618,58 +566,6 @@ async def update_meta(self) -> None: """Update the snippet's metadata.""" self.meta = await self.get_meta_by_path(self) - def to_form( - self, - executor_hosts: Iterable[tuple[str, str]] | None = None, - form_action: str = "", - defaults: dict[str, Any] | None = None, - *, - form_id: str = "snippetExecuteForm", - ) -> str: - """Generate an HTML form for executing the snippet. - - This method creates a form with fields based on the snippet's metadata and - the provided executor hosts. It includes a select element for choosing the - executor host and fields for snippet parameters. - - :param executor_hosts: An iterable of (value, label) tuples for executor hosts. - When ``None``, the executor host fieldset is omitted from the form. - :type executor_hosts: Iterable[tuple[str, str]] | None - :param form_action: The action URL for the form submission. Defaults to an - empty string. - :type form_action: str - :param defaults: Optional dictionary of default values keyed by parameter - name. When provided, matching parameter defaults are overridden before - form generation. - :type defaults: dict[str, Any] | None - :param form_id: The HTML ``id`` attribute for the ``
`` element. - :type form_id: str - :return: An HTML string representing the form for executing the snippet. - :rtype: str - """ - logger.debug( - "Generating execution form for snippet %s (action=%r)", self, form_action - ) - parameters = self.meta.get("parameters", []) - if defaults: - parameters = [ - {**param, "default": defaults[param["name"]]} - if param.get("name") in defaults - else param - for param in parameters - ] - logger.debug("Meta Snippet parameters: %s)", parameters) - return self._to_form( - json_serializer(parameters, sort_keys=True), - executor_hosts, - add_extra_args_field=self.allow_extra_args, - add_sudo_field=self.sudo.is_optional, - sudo_default=self.sudo.sudo_default, - form_action=form_action, - disabled=not self.can_execute, - form_id=form_id, - ) - def get_execution_model(self) -> type[BaseSnippetArgs]: """Generate a Pydantic model for validating snippet execution parameters. @@ -811,101 +707,6 @@ def _validate_visibility_references( ) return errors - @staticmethod - @validate_call - @ttl_cache(ttl=_ONE_HOUR, maxsize=8) - def _to_form( - parameters_json: str, - executor_hosts: frozenset[tuple[str, str]] | None = None, - *, - add_extra_args_field: bool = False, - add_sudo_field: bool = False, - sudo_default: bool = False, - form_action: str = "", - disabled: bool = False, - form_id: str = "snippetExecuteForm", - ) -> str: - """Generate an HTML form for executing a snippet. - - This internal method creates a form with fields based on the snippet's - parameters and the provided executor hosts. It includes a select element for - choosing the executor host and fields for snippet parameters. Parameters - marked ``hidden`` are omitted from the form. It is cached for performance. - - :param parameters_json: A JSON string representing a list of snippet parameters. - :type parameters_json: str - :param executor_hosts: A set of (value, label) tuples for executor hosts. - When ``None``, the executor host fieldset is omitted. - :type executor_hosts: frozenset[tuple[str, str]] | None - :param add_extra_args_field: Whether to include an extra arguments field in the - form. Defaults to ``False``. - :type add_extra_args_field: bool - :param add_sudo_field: Whether to include a sudo checkbox in the form. - Defaults to ``False``. - :type add_sudo_field: bool - :param sudo_default: The default checked state for the sudo checkbox. Only - relevant when ``add_sudo_field`` is ``True``. Defaults to ``False``. - :type sudo_default: bool - :param form_action: The action URL for the form submission. Defaults to an - empty string. - :type form_action: str - :param disabled: Whether to disable the form fields and submit button. Defaults - to ``False``. - :type disabled: bool - :param form_id: The HTML ``id`` attribute for the ```` element. - :type form_id: str - :return: An HTML string representing the form for executing the snippet. - :rtype: str - """ - fieldsets = [] - if executor_hosts is not None: - executor_hosts_fieldset = get_executor_hosts_fieldset(executor_hosts) - executor_hosts_fieldset.disabled = disabled - fieldsets.append(executor_hosts_fieldset) - parameters = BaseSnippet._get_parameters_from_json( - parameters_json - ).visible_parameters - logger.debug("Snippet params: %s", [param.name for param in parameters]) - groups = {} - for param in parameters: - try: - field = param.to_form_field() - except ValidationError: - logger.warning("Invalid snippet parameter: %r", param, exc_info=True) - continue - groups.setdefault(param.group, []).append(field) - if add_extra_args_field: - groups.setdefault(None, []).append(EXTRA_ARGS_INPUT) - if add_sudo_field: - groups.setdefault(None, []).append( - CheckboxInputElement( - name=SUDO_INPUT_NAME, - description="Execute the snippet with sudo", - label="Use sudo", - id="sudoCheckbox", - checked=sudo_default, - ) - ) - for group_name, group_fields in groups.items(): - legend = group_name if group_name is not None else "Parameters" - logger.debug( - "Generated snippet form fields for %r: %s", legend, group_fields - ) - fieldsets.append( - FieldsetElement(legend=legend, children=group_fields, disabled=disabled) - ) - return FormElement( - id=form_id, - children=fieldsets, - submit_button=SubmitButtonElement( - label="Execute", - icon="send", - classes=("text", "medium"), - disabled=disabled, - ), - action=form_action, - ).to_html() - @staticmethod @ttl_cache(ttl=_ONE_HOUR, maxsize=8) def _get_execution_model( diff --git a/app/sep/utils/decorators.py b/app/sep/utils/decorators.py deleted file mode 100644 index e1b744c5dd..0000000000 --- a/app/sep/utils/decorators.py +++ /dev/null @@ -1,32 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define reusable decorators for the SEP app.""" - -from collections.abc import Callable -from functools import wraps -from typing import Any - - -def csrf_exempt(func: Callable[..., Any]) -> Callable[..., Any]: - """Mark a route handler as exempt from CSRF checks.""" - - @wraps(func) - async def wrapper(*args: Any, **kwargs: Any) -> Any: - request = kwargs.get("request") or (args[0] if args else None) - request.state.is_csrf_exempt = True - return await func(*args, **kwargs) - - return wrapper diff --git a/app/sep/utils/static.py b/app/sep/utils/static.py deleted file mode 100644 index b7a607eeff..0000000000 --- a/app/sep/utils/static.py +++ /dev/null @@ -1,45 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define utilities regarding static files.""" - -from fastapi import Request -from starlette.staticfiles import StaticFiles -from starlette.types import Receive, Scope, Send - -from app.sep.deps import get_current_user - - -class AuthenticatedStaticFiles(StaticFiles): - """Define a StaticFiles subclass that enforces authentication.""" - - async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - """Process a request and serve static files ensuring authentication. - - Overrides :py:meth:`starlette.staticfiles.StaticFiles.__call__` to ensure the - user is authenticated through the `Authorization` header or an auth cookie. - - :param scope: The ASGI connection scope containing request information. - :type scope: starlette.types.Scope - :param receive: An asynchronous callable to receive ASGI messages. - :type receive: starlette.types.Receive - :param send: An asynchronous callable to send ASGI messages. - :type send: starlette.types.Send - :raises HTTPException: Raised if the request is not authenticated. - """ - if scope["type"] == "http": - request = Request(scope, receive, send) - await get_current_user(request) - await super().__call__(scope, receive, send) diff --git a/app/tasks/celery.py b/app/tasks/celery.py index 78972ce84c..a0c907ef17 100644 --- a/app/tasks/celery.py +++ b/app/tasks/celery.py @@ -144,9 +144,7 @@ def start_settings_override_refresher(**kwargs: Any) -> None: until a later cycle lands. ``anonymizer_settings._resolve()`` runs unconditionally for validation even - when the refresher is disabled, as ``messages_settings._resolve()`` does in - ``sep_overrides_lifespan``. The two differ in consequence: the lifespan's - call aborts startup, while Celery catches and logs whatever a signal + when the refresher is disabled. Celery catches and logs whatever a signal receiver raises, so an invalid config here leaves the child running without this refresher. diff --git a/app/tasks/migrations/versions/2026_08_08_0306-6a19d56d7985_drop_messages_setting_overrides.py b/app/tasks/migrations/versions/2026_08_08_0306-6a19d56d7985_drop_messages_setting_overrides.py new file mode 100644 index 0000000000..770e05edd1 --- /dev/null +++ b/app/tasks/migrations/versions/2026_08_08_0306-6a19d56d7985_drop_messages_setting_overrides.py @@ -0,0 +1,114 @@ +# Copyright (C) 2026 Percona LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""drop messages setting overrides + +Revision ID: 6a19d56d7985 +Revises: 27a11549ef43 +Create Date: 2026-08-08 03:06:21.383673 + +""" + +import sqlalchemy as sa +from alembic import op + +from app.core.db.utils import ( + acquire_pg_advisory_xact_lock, + check_constraint_lists_members, + table_exists, +) +from app.core.settings_override.constants import SETTINGOVERRIDE_MIGRATION_LOCK_KEY + +# revision identifiers, used by Alembic. +revision = "6a19d56d7985" +down_revision = "27a11549ef43" +branch_labels = None +depends_on = None + +_OLD_MEMBERS = ( + "SEP_SETTINGS", + "TASKS_SETTINGS", + "SNIPPETS_SETTINGS", + "MESSAGES_SETTINGS", + "SETTINGS", + "ALERT_SETTINGS", + "ANONYMIZER_SETTINGS", + "ALERTS_SETTINGS", + "INVENTORY_SETTINGS", +) +_NEW_MEMBERS = tuple(m for m in _OLD_MEMBERS if m != "MESSAGES_SETTINGS") + + +def upgrade() -> None: + """Remove ``MESSAGES_SETTINGS`` from the setting_class constraint.""" + bind = op.get_bind() + acquire_pg_advisory_xact_lock(bind, SETTINGOVERRIDE_MIGRATION_LOCK_KEY) + if not check_constraint_lists_members( + bind, "settingoverride", "setting_class", ("MESSAGES_SETTINGS",) + ): + return + # The narrowed CHECK re-validates existing data, so the rows carrying the + # removed member have to go first or the ALTER aborts. + op.execute("DELETE FROM settingoverride WHERE setting_class = 'MESSAGES_SETTINGS'") + with op.batch_alter_table("settingoverride", schema=None) as batch_op: + batch_op.alter_column( + "setting_class", + existing_type=sa.Enum( + *_OLD_MEMBERS, + name="settingclassenum", + native_enum=False, + create_constraint=True, + ), + type_=sa.Enum( + *_NEW_MEMBERS, + name="settingclassenum", + native_enum=False, + create_constraint=True, + ), + existing_nullable=False, + ) + + +def downgrade() -> None: + """Restore ``MESSAGES_SETTINGS`` in the setting_class constraint. + + The deleted rows are not recreated: they carried a flash-message level for a + middleware that no longer exists. + """ + bind = op.get_bind() + acquire_pg_advisory_xact_lock(bind, SETTINGOVERRIDE_MIGRATION_LOCK_KEY) + if not table_exists(bind, "settingoverride"): + return + if check_constraint_lists_members( + bind, "settingoverride", "setting_class", ("MESSAGES_SETTINGS",) + ): + return + with op.batch_alter_table("settingoverride", schema=None) as batch_op: + batch_op.alter_column( + "setting_class", + existing_type=sa.Enum( + *_NEW_MEMBERS, + name="settingclassenum", + native_enum=False, + create_constraint=True, + ), + type_=sa.Enum( + *_OLD_MEMBERS, + name="settingclassenum", + native_enum=False, + create_constraint=True, + ), + existing_nullable=False, + ) diff --git a/changelog.d/SEP-1687.breaking.md b/changelog.d/SEP-1687.breaking.md new file mode 100644 index 0000000000..c08e502771 --- /dev/null +++ b/changelog.d/SEP-1687.breaking.md @@ -0,0 +1,4 @@ +Remove the legacy server-rendered Jinja UI: all app HTML pages, the login/logout/homepage shell routes, the shared form/AJAX routers (`/inventory-api`, `/stop-task`, `/periodic`), CSRF form validation, flash messages, cookie-based session authentication and the `/static` mounts. The backend is now headless and JSON-only; error responses on non-`/api` paths return JSON instead of redirecting. Browser clients must authenticate with `Authorization: Bearer` — a session cookie alone now yields `401` where it previously redirected with `303`. +Remove the legacy `SEP.SESSION` settings group. `GET /api/sep/admin/settings` no longer lists `SESSION__COOKIE_NAME`, `SESSION__MAX_AGE`, `SESSION__SAMESITE`, `SESSION__SECURE` or `SESSION__PATH`; a stale `SESSION:` block in `settings.yaml` is ignored, and persisted override rows are deleted by a data migration on the `sep` track. `SESSION_REFRESH` (the SPA's `refreshToken` cookie) is unaffected. +Remove the `SEP.TEMPLATES_DIR` and `SEP.STATIC_DIR` settings. The report PDF template and its logo now ship inside the report app (`app/sep/apps/report/{templates,assets}/`) rather than in repository-root `templates/` and `static/` trees, so neither directory is configurable — or present — any more. +Remove the `MessagesSettings` settings class along with the flash-message middleware it configured. `GET /api/sep/admin/settings` no longer lists the `MessagesSettings` group (`SEP.MESSAGES.LEVEL`), `MESSAGES_SETTINGS` is dropped from the `settingoverride.setting_class` vocabulary, and its persisted override rows are deleted by data migrations on the `sep`, `tasks` and `inventory` tracks. diff --git a/data/nginx.conf b/data/nginx.conf index 3637b4804c..bc21b2c8b5 100644 --- a/data/nginx.conf +++ b/data/nginx.conf @@ -94,15 +94,6 @@ server { proxy_redirect off; } - location ^~ /static/ { - proxy_pass https://uvicorn; - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_redirect off; - } - location ^~ /artifacts/ { proxy_pass https://uvicorn; proxy_set_header Host $http_host; diff --git a/frontend/packages/api/specs/inventory.json b/frontend/packages/api/specs/inventory.json index 702c53d84e..35c7314adf 100644 --- a/frontend/packages/api/specs/inventory.json +++ b/frontend/packages/api/specs/inventory.json @@ -1380,12 +1380,11 @@ "type": "object" }, "SettingClassEnum": { - "description": "Enumerate settings classes that may have HOT override rows.\n\nThe wired classes are ``SEPSettings``, ``TasksSettings``,\n``SnippetsSettings``, ``MessagesSettings``, the global ``Settings``,\n``AlertSettings``, ``AlertsSettings``, ``AnonymizerSettings`` and\n``InventorySettings``.\n\nTo wire a new settings class:\n\n1. Add a member here whose value matches the Pydantic class ``__name__``.\n2. Generate an Alembic migration on every consumer track that extends the\n ``CHECK`` constraint on ``settingoverride.setting_class``. The column\n uses ``native_enum=False`` so the value list lives in a constraint,\n not a PostgreSQL ``TYPE`` -- the migration ``ALTER``s the constraint.\n Note that the column and ``CHECK`` constraint persist the enum member\n *names* (e.g. ``SEP_SETTINGS``), which is distinct from the member\n *value* (the Pydantic class name, e.g. ``SEPSettings``).\n3. Wire a ``ProxyEntry`` for the new class in the relevant service's\n lifespan (``app/sep/main.py`` or ``app/tasks/main.py``).", + "description": "Enumerate settings classes that may have HOT override rows.\n\nThe wired classes are ``SEPSettings``, ``TasksSettings``,\n``SnippetsSettings``, the global ``Settings``, ``AlertSettings``,\n``AlertsSettings``, ``AnonymizerSettings`` and ``InventorySettings``.\n\nTo wire a new settings class:\n\n1. Add a member here whose value matches the Pydantic class ``__name__``.\n2. Generate an Alembic migration on every consumer track that extends the\n ``CHECK`` constraint on ``settingoverride.setting_class``. The column\n uses ``native_enum=False`` so the value list lives in a constraint,\n not a PostgreSQL ``TYPE`` -- the migration ``ALTER``s the constraint.\n Note that the column and ``CHECK`` constraint persist the enum member\n *names* (e.g. ``SEP_SETTINGS``), which is distinct from the member\n *value* (the Pydantic class name, e.g. ``SEPSettings``).\n3. Wire a ``ProxyEntry`` for the new class in the relevant service's\n lifespan (``app/sep/main.py`` or ``app/tasks/main.py``).", "enum": [ "SEPSettings", "TasksSettings", "SnippetsSettings", - "MessagesSettings", "Settings", "AlertSettings", "AnonymizerSettings", diff --git a/frontend/packages/api/specs/main.json b/frontend/packages/api/specs/main.json index 43d9e47354..11416c048a 100644 --- a/frontend/packages/api/specs/main.json +++ b/frontend/packages/api/specs/main.json @@ -500,7 +500,7 @@ }, "/api/oauth/session/exchange": { "post": { - "description": "Mint a short-lived SEP bearer from an ambient Grafana session.\n\nServes an embedded client that already carries the host's session cookie on\nthe same origin. Unlike ``POST /session``, set no cookie and issue no refresh\ntoken: the caller holds the bearer in memory and exchanges again before it\nexpires, so the deployment never shares a long-lived credential.\n\nEvery exchange re-reads the identity from the provider and no validation is\ncached, so a role change takes effect within one bearer lifetime. A\nsigned-out browser loses embedded access just as fast, because without the\nsession cookie it cannot obtain another bearer -- though whether a\n*previously copied* host cookie stops working depends on the host revoking\nits own session server-side, which is outside SEP's control.\n\nCarries no auth dependency by design: the caller is not yet SEP-authenticated,\nand the ambient session cookie is the credential being presented. No CSRF\nprimitive applies either -- ``IsCsrfValidated`` guards form data on the\nserver-rendered login route, and requiring a Bearer token cannot gate an\nendpoint whose purpose is to issue one. That is safe because a cross-origin\nattacker cannot read this response (the CORS posture is an explicit\nper-environment origin allowlist, with the middleware absent when none is\nconfigured), and the bearer-authenticated calls the token enables are\nCSRF-exempt by design, since browsers never attach an ``Authorization``\nheader automatically.\n\nEvery failure -- no cookie, a rejected session, an unreachable provider --\ndenies with the same ``401`` rather than reporting a reason the route cannot\ndistinguish.\n\n:param request: The incoming request, carrying the ambient session cookie.\n:param response: The HTTP response, marked uncacheable so no intermediary\n stores the minted bearer.\n:return: The minted bearer and its lifetime.\n:raises HTTPUnauthorizedException: If there is no valid ambient session.", + "description": "Mint a short-lived SEP bearer from an ambient Grafana session.\n\nServes an embedded client that already carries the host's session cookie on\nthe same origin. Unlike ``POST /session``, set no cookie and issue no refresh\ntoken: the caller holds the bearer in memory and exchanges again before it\nexpires, so the deployment never shares a long-lived credential.\n\nEvery exchange re-reads the identity from the provider and no validation is\ncached, so a role change takes effect within one bearer lifetime. A\nsigned-out browser loses embedded access just as fast, because without the\nsession cookie it cannot obtain another bearer -- though whether a\n*previously copied* host cookie stops working depends on the host revoking\nits own session server-side, which is outside SEP's control.\n\nCarries no auth dependency by design: the caller is not yet SEP-authenticated,\nand the ambient session cookie is the credential being presented. No CSRF\nprimitive applies either: requiring a Bearer token cannot gate an\nendpoint whose purpose is to issue one. That is safe because a cross-origin\nattacker cannot read this response (the CORS posture is an explicit\nper-environment origin allowlist, with the middleware absent when none is\nconfigured), and the bearer-authenticated calls the token enables are\nCSRF-exempt by design, since browsers never attach an ``Authorization``\nheader automatically.\n\nEvery failure -- no cookie, a rejected session, an unreachable provider --\ndenies with the same ``401`` rather than reporting a reason the route cannot\ndistinguish.\n\n:param request: The incoming request, carrying the ambient session cookie.\n:param response: The HTTP response, marked uncacheable so no intermediary\n stores the minted bearer.\n:return: The minted bearer and its lifetime.\n:raises HTTPUnauthorizedException: If there is no valid ambient session.", "operationId": "oauth_spa_session_exchange_api_oauth_session_exchange_post", "responses": { "200": { diff --git a/frontend/packages/api/specs/sep.json b/frontend/packages/api/specs/sep.json index 92af64498f..bc876a6c8d 100644 --- a/frontend/packages/api/specs/sep.json +++ b/frontend/packages/api/specs/sep.json @@ -950,12 +950,11 @@ "type": "string" }, "SettingClassEnum": { - "description": "Enumerate settings classes that may have HOT override rows.\n\nThe wired classes are ``SEPSettings``, ``TasksSettings``,\n``SnippetsSettings``, ``MessagesSettings``, the global ``Settings``,\n``AlertSettings``, ``AlertsSettings``, ``AnonymizerSettings`` and\n``InventorySettings``.\n\nTo wire a new settings class:\n\n1. Add a member here whose value matches the Pydantic class ``__name__``.\n2. Generate an Alembic migration on every consumer track that extends the\n ``CHECK`` constraint on ``settingoverride.setting_class``. The column\n uses ``native_enum=False`` so the value list lives in a constraint,\n not a PostgreSQL ``TYPE`` -- the migration ``ALTER``s the constraint.\n Note that the column and ``CHECK`` constraint persist the enum member\n *names* (e.g. ``SEP_SETTINGS``), which is distinct from the member\n *value* (the Pydantic class name, e.g. ``SEPSettings``).\n3. Wire a ``ProxyEntry`` for the new class in the relevant service's\n lifespan (``app/sep/main.py`` or ``app/tasks/main.py``).", + "description": "Enumerate settings classes that may have HOT override rows.\n\nThe wired classes are ``SEPSettings``, ``TasksSettings``,\n``SnippetsSettings``, the global ``Settings``, ``AlertSettings``,\n``AlertsSettings``, ``AnonymizerSettings`` and ``InventorySettings``.\n\nTo wire a new settings class:\n\n1. Add a member here whose value matches the Pydantic class ``__name__``.\n2. Generate an Alembic migration on every consumer track that extends the\n ``CHECK`` constraint on ``settingoverride.setting_class``. The column\n uses ``native_enum=False`` so the value list lives in a constraint,\n not a PostgreSQL ``TYPE`` -- the migration ``ALTER``s the constraint.\n Note that the column and ``CHECK`` constraint persist the enum member\n *names* (e.g. ``SEP_SETTINGS``), which is distinct from the member\n *value* (the Pydantic class name, e.g. ``SEPSettings``).\n3. Wire a ``ProxyEntry`` for the new class in the relevant service's\n lifespan (``app/sep/main.py`` or ``app/tasks/main.py``).", "enum": [ "SEPSettings", "TasksSettings", "SnippetsSettings", - "MessagesSettings", "Settings", "AlertSettings", "AnonymizerSettings", @@ -13725,7 +13724,7 @@ } }, "info": { - "description": "Percona Services Enablement Platform\n\nBrowser-oriented SEP routes (HTML, redirects, proxies, streams). JSON REST APIs for inventory and tasks live on the mounted sub-apps.", + "description": "Percona Services Enablement Platform\n\nBrowser-oriented SEP routes (proxies, streams, downloads). JSON REST APIs for inventory and tasks live on the mounted sub-apps.", "title": "SEP Web Application API", "version": "v1.0.0.dev0" }, diff --git a/frontend/packages/api/specs/tasks.json b/frontend/packages/api/specs/tasks.json index 5ab494d72f..f0bcdcbf11 100644 --- a/frontend/packages/api/specs/tasks.json +++ b/frontend/packages/api/specs/tasks.json @@ -642,12 +642,11 @@ "type": "string" }, "SettingClassEnum": { - "description": "Enumerate settings classes that may have HOT override rows.\n\nThe wired classes are ``SEPSettings``, ``TasksSettings``,\n``SnippetsSettings``, ``MessagesSettings``, the global ``Settings``,\n``AlertSettings``, ``AlertsSettings``, ``AnonymizerSettings`` and\n``InventorySettings``.\n\nTo wire a new settings class:\n\n1. Add a member here whose value matches the Pydantic class ``__name__``.\n2. Generate an Alembic migration on every consumer track that extends the\n ``CHECK`` constraint on ``settingoverride.setting_class``. The column\n uses ``native_enum=False`` so the value list lives in a constraint,\n not a PostgreSQL ``TYPE`` -- the migration ``ALTER``s the constraint.\n Note that the column and ``CHECK`` constraint persist the enum member\n *names* (e.g. ``SEP_SETTINGS``), which is distinct from the member\n *value* (the Pydantic class name, e.g. ``SEPSettings``).\n3. Wire a ``ProxyEntry`` for the new class in the relevant service's\n lifespan (``app/sep/main.py`` or ``app/tasks/main.py``).", + "description": "Enumerate settings classes that may have HOT override rows.\n\nThe wired classes are ``SEPSettings``, ``TasksSettings``,\n``SnippetsSettings``, the global ``Settings``, ``AlertSettings``,\n``AlertsSettings``, ``AnonymizerSettings`` and ``InventorySettings``.\n\nTo wire a new settings class:\n\n1. Add a member here whose value matches the Pydantic class ``__name__``.\n2. Generate an Alembic migration on every consumer track that extends the\n ``CHECK`` constraint on ``settingoverride.setting_class``. The column\n uses ``native_enum=False`` so the value list lives in a constraint,\n not a PostgreSQL ``TYPE`` -- the migration ``ALTER``s the constraint.\n Note that the column and ``CHECK`` constraint persist the enum member\n *names* (e.g. ``SEP_SETTINGS``), which is distinct from the member\n *value* (the Pydantic class name, e.g. ``SEPSettings``).\n3. Wire a ``ProxyEntry`` for the new class in the relevant service's\n lifespan (``app/sep/main.py`` or ``app/tasks/main.py``).", "enum": [ "SEPSettings", "TasksSettings", "SnippetsSettings", - "MessagesSettings", "Settings", "AlertSettings", "AnonymizerSettings", diff --git a/frontend/packages/api/src/client.ts b/frontend/packages/api/src/client.ts index 07f0aa6243..65b703cb53 100644 --- a/frontend/packages/api/src/client.ts +++ b/frontend/packages/api/src/client.ts @@ -17,7 +17,7 @@ /// import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios'; -import { ApiError, normalizeAxiosError } from './errors'; +import { normalizeAxiosError } from './errors'; // ── Token provider ───────────────────────────────────────────────────── // Instead of owning a copy of the access token, the API layer delegates @@ -233,19 +233,6 @@ export function refreshAccessToken(): Promise { apiClient.interceptors.response.use( (response) => { - const ct = String(response.headers['content-type'] ?? ''); - if (ct.includes('text/html') && !isRefreshRequest(response.config?.url)) { - _onUnauthorized(); - return Promise.reject( - new ApiError({ - kind: 'http', - status: 401, - message: 'Session expired (redirected to login page)', - url: response.config?.url, - method: response.config?.method?.toUpperCase(), - }), - ); - } if (IS_DEV) { const method = response.config?.method?.toUpperCase() ?? 'GET'; // eslint-disable-next-line no-console diff --git a/frontend/packages/api/src/generated/inventory.ts b/frontend/packages/api/src/generated/inventory.ts index b4a925189c..fe4ac99a84 100644 --- a/frontend/packages/api/src/generated/inventory.ts +++ b/frontend/packages/api/src/generated/inventory.ts @@ -1288,9 +1288,8 @@ export interface components { * @description Enumerate settings classes that may have HOT override rows. * * The wired classes are ``SEPSettings``, ``TasksSettings``, - * ``SnippetsSettings``, ``MessagesSettings``, the global ``Settings``, - * ``AlertSettings``, ``AlertsSettings``, ``AnonymizerSettings`` and - * ``InventorySettings``. + * ``SnippetsSettings``, the global ``Settings``, ``AlertSettings``, + * ``AlertsSettings``, ``AnonymizerSettings`` and ``InventorySettings``. * * To wire a new settings class: * @@ -1310,7 +1309,6 @@ export interface components { | 'SEPSettings' | 'TasksSettings' | 'SnippetsSettings' - | 'MessagesSettings' | 'Settings' | 'AlertSettings' | 'AnonymizerSettings' diff --git a/frontend/packages/api/src/generated/main.ts b/frontend/packages/api/src/generated/main.ts index 01af8ebd75..54c93c097f 100644 --- a/frontend/packages/api/src/generated/main.ts +++ b/frontend/packages/api/src/generated/main.ts @@ -190,8 +190,7 @@ export interface paths { * * Carries no auth dependency by design: the caller is not yet SEP-authenticated, * and the ambient session cookie is the credential being presented. No CSRF - * primitive applies either -- ``IsCsrfValidated`` guards form data on the - * server-rendered login route, and requiring a Bearer token cannot gate an + * primitive applies either: requiring a Bearer token cannot gate an * endpoint whose purpose is to issue one. That is safe because a cross-origin * attacker cannot read this response (the CORS posture is an explicit * per-environment origin allowlist, with the middleware absent when none is diff --git a/frontend/packages/api/src/generated/sep.ts b/frontend/packages/api/src/generated/sep.ts index d7c44bba54..27ebaa82be 100644 --- a/frontend/packages/api/src/generated/sep.ts +++ b/frontend/packages/api/src/generated/sep.ts @@ -4040,9 +4040,8 @@ export interface components { * @description Enumerate settings classes that may have HOT override rows. * * The wired classes are ``SEPSettings``, ``TasksSettings``, - * ``SnippetsSettings``, ``MessagesSettings``, the global ``Settings``, - * ``AlertSettings``, ``AlertsSettings``, ``AnonymizerSettings`` and - * ``InventorySettings``. + * ``SnippetsSettings``, the global ``Settings``, ``AlertSettings``, + * ``AlertsSettings``, ``AnonymizerSettings`` and ``InventorySettings``. * * To wire a new settings class: * @@ -4062,7 +4061,6 @@ export interface components { | 'SEPSettings' | 'TasksSettings' | 'SnippetsSettings' - | 'MessagesSettings' | 'Settings' | 'AlertSettings' | 'AnonymizerSettings' diff --git a/frontend/packages/api/src/generated/tasks.ts b/frontend/packages/api/src/generated/tasks.ts index 98e478fd86..08c4841187 100644 --- a/frontend/packages/api/src/generated/tasks.ts +++ b/frontend/packages/api/src/generated/tasks.ts @@ -1062,9 +1062,8 @@ export interface components { * @description Enumerate settings classes that may have HOT override rows. * * The wired classes are ``SEPSettings``, ``TasksSettings``, - * ``SnippetsSettings``, ``MessagesSettings``, the global ``Settings``, - * ``AlertSettings``, ``AlertsSettings``, ``AnonymizerSettings`` and - * ``InventorySettings``. + * ``SnippetsSettings``, the global ``Settings``, ``AlertSettings``, + * ``AlertsSettings``, ``AnonymizerSettings`` and ``InventorySettings``. * * To wire a new settings class: * @@ -1084,7 +1083,6 @@ export interface components { | 'SEPSettings' | 'TasksSettings' | 'SnippetsSettings' - | 'MessagesSettings' | 'Settings' | 'AlertSettings' | 'AnonymizerSettings' diff --git a/frontend/packages/api/src/hooks/useSettings.ts b/frontend/packages/api/src/hooks/useSettings.ts index d2d7426d3e..63fbcfda4b 100644 --- a/frontend/packages/api/src/hooks/useSettings.ts +++ b/frontend/packages/api/src/hooks/useSettings.ts @@ -20,7 +20,7 @@ * * Every settings group is reached through the single SEP gateway endpoint * `/api/sep/admin/settings`. SEP serves its own classes (SEPSettings, - * SnippetsSettings, MessagesSettings) locally and proxies `TasksSettings` + * SnippetsSettings) locally and proxies `TasksSettings` * server-side from the Tasks sub-app, so the frontend never calls * `/api/tasks/admin/settings/*` directly (API-First Rule 1). The list response * already carries all groups, so there is no client-side fan-out or merge, and diff --git a/frontend/packages/api/src/typed-client.ts b/frontend/packages/api/src/typed-client.ts index 0ea6a3d2b1..c9e7344324 100644 --- a/frontend/packages/api/src/typed-client.ts +++ b/frontend/packages/api/src/typed-client.ts @@ -53,16 +53,6 @@ const isLoginRequest = (url: string) => url.includes('/oauth/login'); */ const isReplayEligible = (url: string) => !isTokenMintRequest(url) && !isLoginRequest(url); -/** - * A 200 HTML response (e.g. a follow of a login redirect) means the session - * is gone. The browser can't observe the 303, so content-type is the only - * signal. Synthesise a 401 so the normal error path runs. - */ -function isHtmlLoginResponse(response: Response): boolean { - const ct = response.headers.get('content-type') ?? ''; - return response.ok && ct.includes('text/html'); -} - // `fetch` consumes a Request's body stream, so the instance handed to // `onResponse` can no longer be re-sent. Stash an untouched clone taken before // dispatch, keyed weakly so requests that never come back are not retained. @@ -120,14 +110,6 @@ const authMiddleware: Middleware = { ); } - if (isHtmlLoginResponse(response) && !isRefreshRequest(request.url)) { - emitUnauthorized(); - return new Response(null, { - status: 401, - statusText: 'Session expired (redirected to login page)', - }); - } - if (response.status === 401 && isReplayEligible(request.url)) { const replayed = await replayWithFreshToken(request); if (replayed && replayed.status !== 401) { @@ -138,10 +120,9 @@ const authMiddleware: Middleware = { return replayed ?? response; } - if ((response.status === 401 || response.status === 303) && !isRefreshRequest(request.url)) { + if (response.status === 401 && !isRefreshRequest(request.url)) { // A 401 left here is a minting endpoint rejecting the ambient session — - // "not signed in", which the auth layer must hear about. A 303 is the - // login redirect on any endpoint. + // "not signed in", which the auth layer must hear about. emitUnauthorized(); } diff --git a/frontend/packages/api/tests/typed-client.test.ts b/frontend/packages/api/tests/typed-client.test.ts index 0042aec8fc..4946bcd542 100644 --- a/frontend/packages/api/tests/typed-client.test.ts +++ b/frontend/packages/api/tests/typed-client.test.ts @@ -108,22 +108,6 @@ describe('typed-client — auth middleware', () => { expect(onUnauth).not.toHaveBeenCalled(); }); - - it('synthesises a 401 and notifies when the server returns HTML (session expired)', async () => { - const onUnauth = vi.fn(); - setOnUnauthorized(onUnauth); - - server.use( - http.get('http://localhost/api/users/me', () => - HttpResponse.html('login', { status: 200 }), - ), - ); - - await expect(throwOnApiError(mainApi.GET('/api/users/me'))).rejects.toSatisfy( - (err) => err instanceof ApiError && err.status === 401, - ); - expect(onUnauth).toHaveBeenCalledOnce(); - }); }); describe('throwOnApiError', () => { diff --git a/frontend/packages/apps/snippets/src/SnippetsListPage.test.tsx b/frontend/packages/apps/snippets/src/SnippetsListPage.test.tsx index 50d66bcbd2..802f323659 100644 --- a/frontend/packages/apps/snippets/src/SnippetsListPage.test.tsx +++ b/frontend/packages/apps/snippets/src/SnippetsListPage.test.tsx @@ -33,6 +33,16 @@ vi.mock('react-router', () => ({ useNavigate: () => vi.fn(), })); +// The download button owns a mutation now (the SPA fetches the file through the +// Bearer-authenticated JSON endpoint), so the download-before-approve guard +// flips on the mutation's success callback rather than on a plain anchor click. +vi.mock('@sep/framework', () => ({ + useSnippetDownload: vi.fn(() => ({ + mutate: (_params: unknown, callbacks?: { onSuccess?: () => void }) => callbacks?.onSuccess?.(), + isPending: false, + })), +})); + vi.mock('./hooks', () => ({ useSnippets: vi.fn(), useApproveSnippet: vi.fn(), @@ -142,7 +152,7 @@ describe('SnippetsListPage — ApproveButton', () => { it('download action enables the approval confirmation flow', () => { render(); - fireEvent.click(screen.getByRole('link', { name: /download/i })); + fireEvent.click(screen.getByRole('button', { name: /download/i })); fireEvent.click(screen.getByRole('button', { name: /approve/i })); expect(screen.getByRole('dialog')).toBeInTheDocument(); @@ -152,7 +162,7 @@ describe('SnippetsListPage — ApproveButton', () => { it('dialog warns about approving without inspection', () => { render(); - fireEvent.click(screen.getByRole('link', { name: /download/i })); + fireEvent.click(screen.getByRole('button', { name: /download/i })); fireEvent.click(screen.getByRole('button', { name: /approve/i })); const dialog = screen.getByRole('dialog'); @@ -162,7 +172,7 @@ describe('SnippetsListPage — ApproveButton', () => { it('dialog includes the snippet filename', () => { render(); - fireEvent.click(screen.getByRole('link', { name: /download/i })); + fireEvent.click(screen.getByRole('button', { name: /download/i })); fireEvent.click(screen.getByRole('button', { name: /approve/i })); expect(screen.getByRole('dialog')).toHaveTextContent('check.sh'); @@ -171,7 +181,7 @@ describe('SnippetsListPage — ApproveButton', () => { it('Cancel closes the dialog without approving', async () => { render(); - fireEvent.click(screen.getByRole('link', { name: /download/i })); + fireEvent.click(screen.getByRole('button', { name: /download/i })); fireEvent.click(screen.getByRole('button', { name: /approve/i })); expect(screen.getByRole('dialog')).toBeInTheDocument(); @@ -186,7 +196,7 @@ describe('SnippetsListPage — ApproveButton', () => { it('Confirm in dialog calls approve mutation', () => { render(); - fireEvent.click(screen.getByRole('link', { name: /download/i })); + fireEvent.click(screen.getByRole('button', { name: /download/i })); fireEvent.click(screen.getByRole('button', { name: /approve/i })); fireEvent.click(screen.getByRole('button', { name: /^approve$/i })); @@ -448,7 +458,7 @@ describe('SnippetsListPage — RefreshButton', () => { render(); // Download enables the Approve button. - fireEvent.click(screen.getByRole('link', { name: /download/i })); + fireEvent.click(screen.getByRole('button', { name: /download/i })); expect(screen.getByRole('button', { name: /approve/i })).not.toBeDisabled(); // Trigger refresh. diff --git a/frontend/packages/apps/snippets/src/SnippetsListPage.tsx b/frontend/packages/apps/snippets/src/SnippetsListPage.tsx index fd67584d85..b01d6e580a 100644 --- a/frontend/packages/apps/snippets/src/SnippetsListPage.tsx +++ b/frontend/packages/apps/snippets/src/SnippetsListPage.tsx @@ -49,6 +49,7 @@ import DownloadIcon from '@mui/icons-material/Download'; import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'; import SearchIcon from '@mui/icons-material/Search'; import { ApiError, DEFAULT_APP_LIST_LIMIT, DEFAULT_APP_LIST_OFFSET } from '@sep/api'; +import { useSnippetDownload } from '@sep/framework'; import { useSnippets, useApproveSnippet, @@ -125,8 +126,29 @@ interface BatchResult { generic?: string; } -function buildSnippetDownloadUrl(filename: string) { - return `/static/snippets/${filename.split('/').map(encodeURIComponent).join('/')}`; +function DownloadButton({ + snippet, + onDownloaded, +}: { + snippet: SnippetResponse; + onDownloaded: () => void; +}) { + const download = useSnippetDownload(); + + return ( + + + + ); } function ApproveButton({ @@ -686,18 +708,10 @@ export function SnippetsListPage({ isAdmin = false }: SnippetsListPageProps) { {isAdmin && ( e.stopPropagation()}> - - - + markDownloaded(snippet.filename)} + /> . + */ + +import { useMutation } from '@tanstack/react-query'; +import { apiClient } from '@sep/api'; +import { downloadBlob } from '../utils/downloadBlob'; + +export interface SnippetDownloadParams { + /** Snippet filename, relative to the snippets root, as returned by the list. */ + filename: string; +} + +export function useSnippetDownload() { + return useMutation({ + mutationFn: async ({ filename }) => { + const { data } = await apiClient.get('/apps/snippets/snippet/download', { + params: { snippet_filename: filename }, + responseType: 'blob', + }); + const name = filename.split('/').filter(Boolean).pop() ?? filename; + downloadBlob(data, name); + }, + }); +} diff --git a/frontend/packages/framework/src/index.ts b/frontend/packages/framework/src/index.ts index 8641ef588e..fa8a9d2fd4 100644 --- a/frontend/packages/framework/src/index.ts +++ b/frontend/packages/framework/src/index.ts @@ -151,6 +151,7 @@ export { RUNNING_STATUSES, useTaskHistoryFiles, useTaskFileDownload, + useSnippetDownload, } from './hooks'; export type { TaskLogsState, @@ -166,6 +167,7 @@ export type { TaskHistoryFilesMap, UseTaskHistoryFilesOptions, TaskFileDownloadParams, + SnippetDownloadParams, UseSnippetAppExecutionOptions, } from './hooks'; diff --git a/frontend/packages/shell/tests/vite-proxy.config.test.ts b/frontend/packages/shell/tests/vite-proxy.config.test.ts index 008cd4f476..cda3d37d25 100644 --- a/frontend/packages/shell/tests/vite-proxy.config.test.ts +++ b/frontend/packages/shell/tests/vite-proxy.config.test.ts @@ -21,22 +21,37 @@ import viteConfig from '../vite.config'; import viteQaConfig from '../vite.qa.config'; describe('Vite backend proxy prefixes', () => { - it('proxies /static to the local dev backend', () => { + // The SPA fallback would otherwise answer these with index.html: they live + // outside /api, so each needs its own proxy entry. + const NON_API_PREFIXES = ['/stream-logs', '/execution-events', '/files']; + + it('proxies the non-/api backend prefixes to the local dev backend', () => { const proxy = viteConfig.server?.proxy; expect(proxy).toBeDefined(); - expect(proxy?.['/static']).toMatchObject({ - target: 'http://localhost:8000', - changeOrigin: true, - }); + for (const prefix of NON_API_PREFIXES) { + expect(proxy?.[prefix]).toMatchObject({ + target: 'http://localhost:8000', + changeOrigin: true, + }); + } }); - it('proxies /static to the QA backend alongside other entries', () => { + it('proxies the non-/api backend prefixes to the QA backend', () => { const proxy = viteQaConfig.server?.proxy; expect(proxy).toBeDefined(); - expect(proxy?.['/static']).toMatchObject({ - target: process.env.SEP_QA_BACKEND ?? 'http://127.0.0.1:18002', - changeOrigin: true, - cookieDomainRewrite: 'localhost', - }); + for (const prefix of NON_API_PREFIXES) { + expect(proxy?.[prefix]).toMatchObject({ + target: process.env.SEP_QA_BACKEND ?? 'http://127.0.0.1:18002', + changeOrigin: true, + cookieDomainRewrite: 'localhost', + }); + } + }); + + it('drops the retired /static and /legacy proxy entries', () => { + for (const proxy of [viteConfig.server?.proxy, viteQaConfig.server?.proxy]) { + expect(proxy?.['/static']).toBeUndefined(); + expect(proxy?.['/legacy']).toBeUndefined(); + } }); }); diff --git a/frontend/packages/shell/vite.config.ts b/frontend/packages/shell/vite.config.ts index b7b45c4941..5180edef70 100644 --- a/frontend/packages/shell/vite.config.ts +++ b/frontend/packages/shell/vite.config.ts @@ -49,10 +49,6 @@ export default defineConfig({ target: 'http://localhost:8000', changeOrigin: true, }, - '/legacy': { - target: 'http://localhost:8000', - changeOrigin: true, - }, // Task log SSE and related routes live outside /api (see app/sep/main.py). '/stream-logs': { target: 'http://localhost:8000', @@ -66,12 +62,6 @@ export default defineConfig({ target: 'http://localhost:8000', changeOrigin: true, }, - // App static mounts (snippets, dipper) and anonymous legacy Jinja assets - // (see app/sep/main.py). Without this, SPA fallback returns index.html. - '/static': { - target: 'http://localhost:8000', - changeOrigin: true, - }, }, }, optimizeDeps: { diff --git a/frontend/packages/shell/vite.qa.config.ts b/frontend/packages/shell/vite.qa.config.ts index 023a19be68..3a983b7eb9 100644 --- a/frontend/packages/shell/vite.qa.config.ts +++ b/frontend/packages/shell/vite.qa.config.ts @@ -65,13 +65,9 @@ export default defineConfig({ proxy: { '/api': proxyEntry, '/sep_app': proxyEntry, - '/legacy': proxyEntry, '/stream-logs': proxyEntry, '/execution-events': proxyEntry, '/files': proxyEntry, - // App static mounts (snippets, dipper) and anonymous legacy Jinja assets - // (see app/sep/main.py). Without this, SPA fallback returns index.html. - '/static': proxyEntry, }, }, optimizeDeps: { diff --git a/poetry.lock b/poetry.lock index 00c3dc484a..8cd37de34d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.0 and should not be changed by hand. [[package]] name = "aiofiles" @@ -1014,7 +1014,7 @@ version = "8.4.1" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" -groups = ["main", "audit", "dev"] +groups = ["main", "dev"] files = [ {file = "click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2"}, {file = "click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96"}, @@ -1104,7 +1104,7 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} +markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\"", audit = "platform_system == \"Windows\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} [[package]] name = "confection" @@ -1314,23 +1314,6 @@ cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy [package.extras] ssh = ["bcrypt (>=3.1.5)"] -[[package]] -name = "cssbeautifier" -version = "1.15.4" -description = "CSS unobfuscator and beautifier." -optional = false -python-versions = "*" -groups = ["audit"] -files = [ - {file = "cssbeautifier-1.15.4-py3-none-any.whl", hash = "sha256:78c84d5e5378df7d08622bbd0477a1abdbd209680e95480bf22f12d5701efc98"}, - {file = "cssbeautifier-1.15.4.tar.gz", hash = "sha256:9bb08dc3f64c101a01677f128acf01905914cf406baf87434dcde05b74c0acf5"}, -] - -[package.dependencies] -editorconfig = ">=0.12.2" -jsbeautifier = "*" -six = ">=1.13.0" - [[package]] name = "cssselect2" version = "0.9.0" @@ -1474,83 +1457,6 @@ files = [ {file = "distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed"}, ] -[[package]] -name = "djlint" -version = "1.39.2" -description = "HTML Template Linter and Formatter" -optional = false -python-versions = ">=3.10" -groups = ["audit"] -files = [ - {file = "djlint-1.39.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8d21abcd252e3f28f13f5090f2c214f49e91a53b71c0e949460fb8e3065634c6"}, - {file = "djlint-1.39.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f3d1a797c348115ee3fbea39751223721247501f74728cdb6be6776f2e663cd5"}, - {file = "djlint-1.39.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69830fb1d0e0f5ccd09d248bb9bc4adf6f9937ea76037e68cc3dda7ec33c4701"}, - {file = "djlint-1.39.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17979198e7084b5e24bc22b89be89d12977b37304d6a24e6d382f6699d96ecc2"}, - {file = "djlint-1.39.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ea0863fd53bd21e75ea0d04711898855bd579d6bd9783dae797deea82b428500"}, - {file = "djlint-1.39.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:820ce7467d6200a7435155bbd0a080316d7c16a5c4730712c2e3b8c905657430"}, - {file = "djlint-1.39.2-cp310-cp310-win32.whl", hash = "sha256:3343ae644cf3e16aaf4b3027833255c93bde9798521d5f8481a7099adce2dba5"}, - {file = "djlint-1.39.2-cp310-cp310-win_amd64.whl", hash = "sha256:0877d6a6db2f88dc8ae16752605f8852d8503e5939f4dc48ba6520f6ba2dfade"}, - {file = "djlint-1.39.2-cp310-cp310-win_arm64.whl", hash = "sha256:9ab8822909d084148506450b49a9abfd13e62423cb7b8d89cd9153ada53ac1e0"}, - {file = "djlint-1.39.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8a30c4b46914ec15e8260b1f935f2a9d786111852fb069e3fb9c9c2f46fcae74"}, - {file = "djlint-1.39.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7483e8b46eb6c92f2275024452d762b2bff09278238d0bbc6ad181f969b9a4dc"}, - {file = "djlint-1.39.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:81314434617ac888d1f53f4cf8ffb718bad79f454bc3a6af2874d347e0ab8a8f"}, - {file = "djlint-1.39.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:473803d2a6bff1bc61c0bfac3f3c36ebe58348342a3c234275969c57eeba8814"}, - {file = "djlint-1.39.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:33aa6da970c7ed3e62573e00d1aaf7f3f234361b571e55d0a916d300a69bffc6"}, - {file = "djlint-1.39.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c261e83fca81ed7dfc992f5bae9013e33cb5a6df5a71f96476962fd13c4e734c"}, - {file = "djlint-1.39.2-cp311-cp311-win32.whl", hash = "sha256:73e5277c5c553935448d209643a7adeb9fe185550ec9007470c132ccaa5ff339"}, - {file = "djlint-1.39.2-cp311-cp311-win_amd64.whl", hash = "sha256:117af0b57f71c8531c3fbb95162c20e74ace0d957f65ac93539ff42e84b778c8"}, - {file = "djlint-1.39.2-cp311-cp311-win_arm64.whl", hash = "sha256:af01dd3555f65dcb3d735a7254ead88ddb50e6dad76a12af22c85ff3ea66a062"}, - {file = "djlint-1.39.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4f26ec23a39c1ef45f2357e95395aa566010d513282ed34a06f0f54690888b0"}, - {file = "djlint-1.39.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f907116317fa429d9e29aac529001fdeec48a862131154e5792ebcd9d5415012"}, - {file = "djlint-1.39.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8eacde1123ace11f43029e565850861e73f4d7f44aaeb07c2af1ce40dc242d84"}, - {file = "djlint-1.39.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:525b0cf09ff13b84a17c704fb7e34144d94fd49fa0a29e23e70394487b3f382b"}, - {file = "djlint-1.39.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a54dac1106ce607b134faa59acca01bfb3f0b36d6ccc562558d90fb0ddce55f2"}, - {file = "djlint-1.39.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6767bc0b0a04a022308d94be004e1889801cdac1f858563f6c397094580d78e1"}, - {file = "djlint-1.39.2-cp312-cp312-win32.whl", hash = "sha256:059dbea42758394d6fae582e3b158e098ebdc9cb4eb68634bcb3334f95c55799"}, - {file = "djlint-1.39.2-cp312-cp312-win_amd64.whl", hash = "sha256:38129305d27f5e635404b131f21a2a4d8ffcc1f58446c0f3b4a25b38140ec710"}, - {file = "djlint-1.39.2-cp312-cp312-win_arm64.whl", hash = "sha256:2382bfc52d2bf9ca9565696e4ee89ce811783090f36b4b03ce30888672da9506"}, - {file = "djlint-1.39.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c243a1f18211cb9a4fec5b95f0b43c61941414f71fdb3d77dcc9798f9644b623"}, - {file = "djlint-1.39.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:74d65499c5b29e5269eb6a4bff9dbf426c22cba54f4810d30fbfbcb482c9035f"}, - {file = "djlint-1.39.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25b955e23c09bdfdb04de6b40b848cf93b39952178aa37d940af921a9715da4b"}, - {file = "djlint-1.39.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0a2c0191f93181557e8dcbca2a6766ab972f769bb9c32d488cefa1b5195a74d"}, - {file = "djlint-1.39.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e0aa5e3087dd346ae3197781fa425aebccce6beed593f4dd17cbb00179ef8444"}, - {file = "djlint-1.39.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:30094c7533b0e919f4674baeaf0acd061b1eb6c8ffbf4d6d3dc856c686747411"}, - {file = "djlint-1.39.2-cp313-cp313-win32.whl", hash = "sha256:3eb8942afbf2e5d0ffc208d88db33dc38abb585ce03fdd17d9a0f03e4b7e761a"}, - {file = "djlint-1.39.2-cp313-cp313-win_amd64.whl", hash = "sha256:9040e8ff1e7e141cd67402110f0edb67835955003c7a076c97398dcee5f814d1"}, - {file = "djlint-1.39.2-cp313-cp313-win_arm64.whl", hash = "sha256:a54edd4326adc48577d244425e8d3c175ec3cc6abd3732418efeccc0b92b9d1a"}, - {file = "djlint-1.39.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e2fefc27180dad754e76c9ece6f5dbe99a808c7a0dd4ff4213dd39d82f1878cd"}, - {file = "djlint-1.39.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c62372a9d037d612f9b957829c0dbdef96e017dc2d9cf4970e094de6fee4ee55"}, - {file = "djlint-1.39.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d27a88e84fd9e75cc49247b9d200f51c7bd037364efd58256d3b0e70c10775db"}, - {file = "djlint-1.39.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13b77ce23aabab2426f5130948c0d605bd7414550bbca8a3892b7d255644c2da"}, - {file = "djlint-1.39.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4547b35fd6f5030ffe4555e66916f8ba8107e0e9c2c6a4e721fb980ba79e3fce"}, - {file = "djlint-1.39.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1257a2db80184ba70a195d8fe6026b54dab4dbc5156a90a61adbc916e500a6b1"}, - {file = "djlint-1.39.2-cp314-cp314-win32.whl", hash = "sha256:766d0266fbb0064c7579d66ed06487d9198d99880c5bbab03f24386edfa766f5"}, - {file = "djlint-1.39.2-cp314-cp314-win_amd64.whl", hash = "sha256:345608f6eefe627d39f2491a673bc80688a86af3977113fc91b01f4b827bec2a"}, - {file = "djlint-1.39.2-cp314-cp314-win_arm64.whl", hash = "sha256:ed0d24f5af98f7ef8c50061b64ab4cdd61abfd133df9f99810d3efc82e25a3c1"}, - {file = "djlint-1.39.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:16cc5df9891a80090600f32111448e175148670cb7ca7789c04c4cdf9f9334d8"}, - {file = "djlint-1.39.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c20df1b4a79bc05329c207110478ad26513deaef3a7409b4a3cab55216172749"}, - {file = "djlint-1.39.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c2f34d9ef9aa73536bf485648ce4fa381bb3d8d843fb97ec6725d5b3457b84d"}, - {file = "djlint-1.39.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:851a9c78674120c99fe6842bcd3377bce698583823ee7861ae4992e84fff3e9b"}, - {file = "djlint-1.39.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c9df4a39d57476bcd2a02ae8d873796b92517517d4512f497b40be3e7398e84"}, - {file = "djlint-1.39.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e0356e3a66357d72ca5d69f2fb78d47fed26efb31d104cba44f5e6fbf503a4e2"}, - {file = "djlint-1.39.2-cp314-cp314t-win32.whl", hash = "sha256:ad5213ce1bbab6c18e1461498f219ca66382c40766d499e233ff2174a23026c6"}, - {file = "djlint-1.39.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d5711a2945844446fa19c35f151e077c2119411e49db30a3f97f32b9325b89bf"}, - {file = "djlint-1.39.2-cp314-cp314t-win_arm64.whl", hash = "sha256:f894464e6946e761003844ac5bfe4f51f362258a51d4e65a9641a4bd74da4ea2"}, - {file = "djlint-1.39.2-py3-none-any.whl", hash = "sha256:7c49bfda97816afd36273f12d67aa432ed44cd9f62d5c31f9ea9c316ebe808b2"}, - {file = "djlint-1.39.2.tar.gz", hash = "sha256:dd27ef03bd26d1e0a167da26ec2a6fcb511dddd032b2553abf71a5c31eaa8b34"}, -] - -[package.dependencies] -click = ">=8.0.1" -colorama = ">=0.4.4" -cssbeautifier = ">=1.14.4" -jsbeautifier = ">=1.14.4" -json5 = ">=0.9.11" -pathspec = ">=0.12" -pyyaml = ">=6" -regex = ">=2023" -tqdm = ">=4.62.2" - [[package]] name = "dnspython" version = "2.8.0" @@ -1572,21 +1478,6 @@ idna = ["idna (>=3.10)"] trio = ["trio (>=0.30)"] wmi = ["wmi (>=1.5.1) ; platform_system == \"Windows\""] -[[package]] -name = "editorconfig" -version = "0.17.1" -description = "EditorConfig File Locator and Interpreter for Python" -optional = false -python-versions = ">=3.9" -groups = ["audit"] -files = [ - {file = "editorconfig-0.17.1-py3-none-any.whl", hash = "sha256:1eda9c2c0db8c16dbd50111b710572a5e6de934e39772de1959d41f64fc17c82"}, - {file = "editorconfig-0.17.1.tar.gz", hash = "sha256:23c08b00e8e08cc3adcddb825251c497478df1dada6aefeb01e626ad37303745"}, -] - -[package.extras] -dev = ["mypy (>=1.15)"] - [[package]] name = "email-validator" version = "2.3.0" @@ -2329,34 +2220,6 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] -[[package]] -name = "jsbeautifier" -version = "1.15.4" -description = "JavaScript unobfuscator and beautifier." -optional = false -python-versions = "*" -groups = ["audit"] -files = [ - {file = "jsbeautifier-1.15.4-py3-none-any.whl", hash = "sha256:72f65de312a3f10900d7685557f84cb61a9733c50dcc27271a39f5b0051bf528"}, - {file = "jsbeautifier-1.15.4.tar.gz", hash = "sha256:5bb18d9efb9331d825735fbc5360ee8f1aac5e52780042803943aa7f854f7592"}, -] - -[package.dependencies] -editorconfig = ">=0.12.2" -six = ">=1.13.0" - -[[package]] -name = "json5" -version = "0.14.0" -description = "A Python implementation of the JSON5 data format." -optional = false -python-versions = ">=3.8.0" -groups = ["audit"] -files = [ - {file = "json5-0.14.0-py3-none-any.whl", hash = "sha256:56cf861bab076b1178eb8c92e1311d273a9b9acea2ccc82c276abf839ebaef3a"}, - {file = "json5-0.14.0.tar.gz", hash = "sha256:b3f492fad9f6cdbced8b7d40b28b9b1c9701c5f561bef0d33b81c2ff433fefcb"}, -] - [[package]] name = "kombu" version = "5.6.2" @@ -3155,23 +3018,6 @@ files = [ {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, ] -[[package]] -name = "pathspec" -version = "1.1.1" -description = "Utility library for gitignore style pattern matching of file paths." -optional = false -python-versions = ">=3.9" -groups = ["audit"] -files = [ - {file = "pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189"}, - {file = "pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a"}, -] - -[package.extras] -hyperscan = ["hyperscan (>=0.7)"] -optional = ["typing-extensions (>=4)"] -re2 = ["google-re2 (>=1.1)"] - [[package]] name = "phonenumbers" version = "9.0.32" @@ -4406,7 +4252,7 @@ version = "2026.5.9" description = "Alternative regular expression module, to replace re." optional = false python-versions = ">=3.10" -groups = ["main", "audit"] +groups = ["main"] files = [ {file = "regex-2026.5.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a9e1328e17c84c1a5d22ec9f785ecef4a967fab9a42b6a8dc3bcbebd0a0c9e44"}, {file = "regex-2026.5.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bfe1ce50cbfb569d74e1e4337da6468961f31dbea55fd85aa5de59c0947a805a"}, @@ -4757,7 +4603,7 @@ version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main", "audit", "dev"] +groups = ["main", "dev"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -5392,7 +5238,7 @@ version = "4.68.2" description = "Fast, Extensible Progress Meter" optional = false python-versions = ">=3.7" -groups = ["main", "audit"] +groups = ["main"] files = [ {file = "tqdm-4.68.2-py3-none-any.whl", hash = "sha256:d4240441fb5353290b87d6a85968c9decc131a99b8c7faa28269d829de669ede"}, {file = "tqdm-4.68.2.tar.gz", hash = "sha256:89c230e8dbc67c7615c142487111222f878c77427ea09549960f62389e258add"}, @@ -6197,4 +6043,4 @@ test = ["pytest"] [metadata] lock-version = "2.1" python-versions = ">=3.11.9,!=3.12.0,!=3.12.1,!=3.12.2,<3.14" -content-hash = "85fbbaa2a3722aa779678e53b5ec5c5d76c4e778cab19bd222ce6edc517b0c2e" +content-hash = "57e9d1bf72e5a7a41bd75351113bf43fa0701166e5e0332d35ad6473651cc4ef" diff --git a/pyproject.toml b/pyproject.toml index ee0514235f..088cdc92fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,9 +81,6 @@ optional = true pip-audit = "^2.10.0" bandit = {extras = ["toml"], version = "^1.7.9"} ruff = "^0.9.4" -djlint = "^1.36.4" -jsbeautifier = "^1.15.1" -cssbeautifier = "^1.15.1" [tool.poetry.group.typecheck] optional = true @@ -181,7 +178,6 @@ python-version = "3.11" exclude = [ "**/migrations/**", "frontend/**", - "static/**", ] [tool.ruff] @@ -364,7 +360,7 @@ fail_under = 75 source_paths = ["app/"] only_mutate = ["app/core/db/utils.py"] pytest_add_cli_args_test_selection = ["tests/app/core/db/test_utils.py"] -also_copy = ["settings.yaml", "templates", "static", "snippets", "data"] +also_copy = ["settings.yaml", "snippets", "data"] [tool.tox] env_list = ["3.11", "3.12", "3.13"] @@ -372,9 +368,3 @@ env_list = ["3.11", "3.12", "3.13"] [tool.tox.env_run_base] allowlist_externals = ["make"] commands = [["make", "test"]] - - -[tool.djlint] -profile = "jinja" -extension = "html.j2" -ignore = "H021" diff --git a/scripts/sync_labeler_apps.py b/scripts/sync_labeler_apps.py index 832bcad4b1..dc394efd99 100755 --- a/scripts/sync_labeler_apps.py +++ b/scripts/sync_labeler_apps.py @@ -23,11 +23,11 @@ ``scripts/sync_alembic_version_locations.py``). An app slice is the full-stack vertical for one app: ``app/sep/apps//``, -``frontend/packages/apps//``, ``tests/app/sep/apps//``, -``templates//``, and ``frontend/packages/e2e/tests/*.spec.ts``. Only -surfaces that exist on disk are emitted. Three name mismatches are resolved by -explicit alias maps that are asserted against disk, so a stale alias fails the -``--check`` mode instead of silently emitting a dead glob. +``frontend/packages/apps//``, ``tests/app/sep/apps//``, and +``frontend/packages/e2e/tests/*.spec.ts``. Only surfaces that exist on +disk are emitted. Name mismatches are resolved by an explicit alias map that is +asserted against disk, so a stale alias fails the ``--check`` mode instead of +silently emitting a dead glob. Run without arguments to rewrite in place; run with ``--check`` to fail without writing when the committed block has drifted. @@ -46,10 +46,6 @@ #: Directories under ``app/sep/apps`` that are framework internals, not apps. EXCLUDED_APPS = frozenset({"framework", "shared"}) -#: App name -> ``templates/`` name, where the template directory that backs -#: the app does not share its name. Asserted against disk by ``--check``. -TEMPLATE_ALIASES = {"archives": "archiver"} - #: App name -> e2e spec stem, where the Playwright spec uses hyphens while the #: app directory uses underscores. Asserted against disk by ``--check``. E2E_ALIASES = { @@ -99,21 +95,10 @@ def validate_aliases(apps: list[str], repo_root: Path) -> None: :param apps: Discovered app-slice names. :param repo_root: Repository root the surfaces are resolved against. - :raises ValueError: When an alias key is not a known app, or its target - directory (templates) or spec (e2e) is missing on disk. + :raises ValueError: When an alias key is not a known app, or its e2e spec is + missing on disk. """ app_set = set(apps) - for app, template_dir in TEMPLATE_ALIASES.items(): - if app not in app_set: - msg = f"template alias references unknown app: {app!r}" - raise ValueError(msg) - if not (repo_root / "templates" / template_dir).is_dir(): - msg = ( - f"stale template alias {app!r} -> templates/{template_dir}: " - "directory does not exist" - ) - raise ValueError(msg) - e2e_dir = repo_root / "frontend" / "packages" / "e2e" / "tests" for app, stem in E2E_ALIASES.items(): if app not in app_set: @@ -128,7 +113,7 @@ def app_globs(app: str, repo_root: Path) -> list[str]: """Return the POSIX globs for one app slice, in deterministic order. Only surfaces that exist on disk are emitted, so the config stays truthful - as apps migrate away from ``templates/`` or gain a React package. + as apps gain or drop a React package. :param app: The app-slice name. :param repo_root: Repository root the surfaces are resolved against. @@ -144,10 +129,6 @@ def app_globs(app: str, repo_root: Path) -> list[str]: if tests_dir.is_dir(): globs.append(f"tests/app/sep/apps/{app}/**") - template_dir = TEMPLATE_ALIASES.get(app, app) - if (repo_root / "templates" / template_dir).is_dir(): - globs.append(f"templates/{template_dir}/**") - e2e_stem = E2E_ALIASES.get(app, app) if _e2e_matches(repo_root / "frontend" / "packages" / "e2e" / "tests", e2e_stem): globs.append(f"frontend/packages/e2e/tests/{e2e_stem}*.spec.ts") diff --git a/settings.yaml b/settings.yaml index e262542710..77b39ee9cf 100644 --- a/settings.yaml +++ b/settings.yaml @@ -54,17 +54,9 @@ default: PROXY_HEADERS: False ARTIFACT_DOWNLOAD_TTL: 600 CONNECTIVITY_CHECK_DEFAULT: False - TEMPLATES_DIR: templates - STATIC_DIR: static ALERT_DEFINITIONS_DIR: null OAUTH: REDIRECT_URI: /oauth/callback - SESSION: - COOKIE_NAME: casdoorToken - SECURE: False - HTTP_ONLY: True - SAME_SITE: lax - MAX_AGE: 3600 SESSION_REFRESH: COOKIE_NAME: refreshToken PATH: /api/oauth @@ -401,9 +393,6 @@ production_docker: - /api/docs - /api/inventory/docs - /api/tasks/docs - SESSION: - COOKIE_NAME: casdoorToken - SECURE: True SESSION_REFRESH: COOKIE_NAME: refreshToken PATH: /api/oauth diff --git a/sidecar/settings.embedded.yaml b/sidecar/settings.embedded.yaml index 92d618bc61..001a10c91d 100644 --- a/sidecar/settings.embedded.yaml +++ b/sidecar/settings.embedded.yaml @@ -37,7 +37,6 @@ default: - SEPSettings.CONNECTIVITY_CHECK_DEFAULT - SEPSettings.FOOTER_TEMPLATE - SEPSettings.DIAGNOSTICS_DELIVERY_INPUTS - - MessagesSettings.LEVEL - AlertSettings.PROVIDERS - AlertSettings.SOURCE_PREFIX - AlertSettings.SOURCE_SUFFIX diff --git a/static/css/alert-troubleshooting.css b/static/css/alert-troubleshooting.css deleted file mode 100644 index d7b2f3ae40..0000000000 --- a/static/css/alert-troubleshooting.css +++ /dev/null @@ -1,306 +0,0 @@ -/** - * Copyright (C) 2026 Percona LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -/* Alert Troubleshooting plugin styles */ - -.at-accordion { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.at-section { - border: 0.0625rem solid var(--lineDivider); - border-radius: 0.5rem; - background: var(--surfaceElevation1); - overflow: hidden; -} - -.at-section-header { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.75rem 1rem; - cursor: pointer; - user-select: none; - list-style: none; - transition: background 200ms ease; -} - -.at-section-header:hover { - background: var(--surfaceElevation2); -} - -.at-section-header:focus-visible { - outline: 0.1875rem solid var(--lineFocus); - outline-offset: -0.1875rem; -} - -.at-section-header::-webkit-details-marker { - display: none; -} - -.at-section-header::marker { - content: ''; - display: none; -} - -.at-section-title { - display: flex; - align-items: center; - gap: 0.5rem; - flex: 1; - font-weight: 500; - font-size: 1rem; - color: var(--textPrimary); -} - -.at-section-icon { - font-size: 1.25rem; - color: var(--textSecondary); -} - -.at-badge { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 1.5rem; - height: 1.5rem; - padding: 0 0.375rem; - border-radius: 0.75rem; - background: var(--surfaceElevation2); - color: var(--textSecondary); - font-size: 0.75rem; - font-weight: 500; -} - -.at-chevron { - font-size: 1.5rem; - color: var(--textSecondary); - transition: transform 200ms ease; -} - -.at-section[open]>.at-section-header .at-chevron { - transform: rotate(180deg); -} - -.at-section-body { - display: grid; - grid-template-rows: 1fr; - transition: grid-template-rows 200ms ease; -} - -.at-section:not([open])>.at-section-body { - grid-template-rows: 0fr; -} - -.at-alert-list { - list-style: none; - margin: 0; - padding: 0; - overflow: hidden; - border-top: 0.0625rem solid var(--lineDivider); -} - -.at-alert-list li { - border-bottom: 0.0625rem solid var(--lineContour); -} - -.at-alert-list li:last-child { - border-bottom: none; -} - -.at-alert-link { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.625rem 1rem 0.625rem 2.75rem; - color: var(--textPrimary); - text-decoration: none; - cursor: pointer; - transition: background 150ms ease; -} - -.at-alert-link:hover { - background: var(--surfaceElevation2); -} - -.at-alert-link:focus-visible { - outline: 0.1875rem solid var(--lineFocus); - outline-offset: -0.1875rem; -} - -.at-alert-icon { - font-size: 1.125rem; - color: var(--textSecondary); -} - -.at-empty-state { - color: var(--textSecondary); - padding: 2rem 0; -} - -.at-empty-state code { - background: var(--surfaceElevation2); - padding: 0.125rem 0.375rem; - border-radius: 0.25rem; - font-size: 0.875rem; -} - -/* Detail page — shared host selector */ - -.at-host-selector { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.75rem 1rem; - margin-bottom: 1.5rem; - border: 0.0625rem solid var(--lineDivider); - border-radius: 0.5rem; - background: var(--surfaceElevation1); -} - -.at-host-selector label { - font-weight: 500; - color: var(--textPrimary); - white-space: nowrap; -} - -.at-host-select { - flex: 1; - max-width: 24rem; - padding: 0.375rem 0.5rem; - border: 0.0625rem solid var(--lineDivider); - border-radius: 0.25rem; - background: var(--surfaceElevation0); - color: var(--textPrimary); - font-size: 0.875rem; - cursor: pointer; -} - -.at-host-select:focus-visible { - outline: 0.1875rem solid var(--lineFocus); - outline-offset: -0.1875rem; -} - -/* Detail page — snippet cards */ - -.at-snippet-cards { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.at-snippet-card { - border: 0.0625rem solid var(--lineDivider); - border-radius: 0.5rem; - background: var(--surfaceElevation1); - overflow: hidden; -} - -.at-snippet-header { - padding: 0.75rem 1rem 0; -} - -.at-snippet-title { - margin: 0; - font-size: 1rem; - font-weight: 500; - color: var(--textPrimary); -} - -.at-snippet-desc { - margin: 0.25rem 0 0; - font-size: 0.875rem; - color: var(--textSecondary); -} - -.at-snippet-form { - padding: 0.5rem 1rem 0.75rem; -} - -.at-disabled-warning { - display: inline-flex; - align-items: center; - gap: 0.375rem; - color: var(--textSecondary); - font-size: 0.875rem; -} - -.at-disabled-warning .material-symbols-outlined { - font-size: 1.125rem; - color: var(--warningMain); -} - -/* Detail page — terminal output area */ - -.at-output-area { - position: relative; - margin: 0 1rem 0.75rem; - border-radius: 0.375rem; - background: #1e1e1e; - overflow: hidden; -} - -[data-theme="dark"] .at-output-area { - background: var(--surfaceElevation2); -} - -.at-output-label { - position: absolute; - top: 0.375rem; - right: 0.5rem; - font-size: 0.625rem; - font-weight: 600; - letter-spacing: 0.05em; - color: rgba(255, 255, 255, 0.4); - text-transform: uppercase; - pointer-events: none; -} - -.at-output-pre { - margin: 0; - padding: 1.25rem 1rem 0.75rem; - font-family: "Roboto Mono", ui-monospace, monospace; - font-size: 0.8125rem; - line-height: 1.5; - color: #d4d4d4; - white-space: pre-wrap; - word-break: break-all; - max-height: 24rem; - overflow-y: auto; - overflow-x: auto; -} - -/* Detail page — loading and feedback states */ - -.at-snippet-card .at-run-loading button[type="submit"] { - opacity: 0.6; - pointer-events: none; -} - -.at-run-error { - color: var(--errorMain); - font-size: 0.8125rem; - padding: 0.25rem 1rem; -} - -.at-run-success { - color: var(--successMain); - font-size: 0.8125rem; - padding: 0.25rem 1rem; -} diff --git a/static/css/alerts.css b/static/css/alerts.css deleted file mode 100644 index 40f036f3e2..0000000000 --- a/static/css/alerts.css +++ /dev/null @@ -1,355 +0,0 @@ -/* Copyright (C) 2026 Percona LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - */ - -/* Badges (shared base for severity and PMM presence) */ - -.alert-badge { - display: inline-block; - padding: 0.125rem 0.5rem; - border-radius: var(--cornerExtraSmall); - font-size: 0.8125rem; - font-weight: 500; - line-height: 1.5; - white-space: nowrap; -} - -/* Severity badges */ - -.severity-info { - background: var(--infoSurface); - color: var(--infoContrast); -} - -.severity-warning { - background: var(--warningSurface); - color: var(--warningContrast); -} - -.severity-critical { - background: var(--errorSurface); - color: var(--errorContrast); -} - -/* PMM presence badges */ - -.badge-present { - background: var(--successSurface); - color: var(--successContrast); -} - -.badge-absent { - background: var(--surfaceElevation2); - color: var(--textSecondary); -} - -.badge-unknown { - background: var(--warningSurface); - color: var(--warningContrast); -} - -/* Alerts table */ - -.alerts-table { - width: 100%; - border-collapse: collapse; -} - -.alerts-table th, -.alerts-table td { - padding: 0.75rem 0.5rem; - text-align: left; - border-bottom: 0.0625rem solid var(--lineDivider); -} - -.alerts-table thead th { - font-weight: 600; - color: var(--textSecondary); - font-size: 0.8125rem; - text-transform: uppercase; - letter-spacing: 0.025em; -} - -.alerts-table tbody tr:hover { - background: var(--surfaceElevation1); -} - -.col-checkbox { - width: 2.5rem; -} - -/* Alert detail modal */ - -.alert-detail-modal { - min-width: 30rem; - max-width: 50rem; -} - -.alert-detail-list { - display: grid; - grid-template-columns: 10rem 1fr; - gap: 0.5rem 1rem; - align-items: baseline; -} - -.alert-detail-list dt { - font-weight: 600; - color: var(--textSecondary); -} - -.alert-detail-list dd { - margin: 0; -} - -code.expression { - display: block; - padding: 0.5rem; - background: var(--surfaceElevation2); - border-radius: var(--cornerExtraSmall); - font-size: 0.8125rem; - word-break: break-all; - white-space: pre-wrap; -} - -/* PagerDuty widget */ - -.pd-status { - margin: 1rem 0; -} - -.pd-form { - margin-top: 1rem; - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.pd-form label { - font-size: 0.8125rem; - font-weight: 500; - color: var(--textSecondary); -} - -.pd-form input[type="password"], -.pd-form input[type="text"] { - width: 100%; - padding: 0.375rem 0.5rem; - border: 1px solid var(--divider); - border-radius: var(--cornerExtraSmall); - font-size: 0.8125rem; - background: var(--surfaceElevation1); - color: var(--textPrimary); -} - -.pd-form-actions { - display: flex; - gap: 0.5rem; - align-items: center; -} - -.pd-feedback { - margin-top: 0.5rem; - font-size: 0.8125rem; - min-height: 1.25rem; -} - -.pd-feedback.success { - color: var(--successContrast); -} - -.pd-feedback.error { - color: var(--errorContrast); -} - -.text-muted { - color: var(--textSecondary); - font-size: 0.875rem; -} - -/* Sticky bar layout */ - -.sticky-bar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; -} - -/* Push to PMM button */ - -.push-btn { - white-space: nowrap; -} - -.push-btn:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -/* Push results */ - -.push-results { - margin-top: 1rem; -} - -.push-result { - padding: 0.5rem 0.75rem; - margin-bottom: 0.25rem; - border-radius: var(--cornerExtraSmall); - font-size: 0.875rem; -} - -.push-result-success { - background: var(--successSurface); - color: var(--successContrast); -} - -.push-result-error { - background: var(--errorSurface); - color: var(--errorContrast); -} - -.push-result-skipped { - background: var(--surfaceElevation2); - color: var(--textSecondary); -} - -/* Two-column layout: main content + sidebar */ - -.alerts-layout { - display: grid; - grid-template-columns: 1fr 18rem; - gap: 2rem; - align-items: start; -} - -@media (max-width: 1024px) { - .alerts-layout { - grid-template-columns: 1fr; - } -} - -.alerts-main { - min-width: 0; -} - -/* Sidebar widget */ - -.alerts-sidebar { - display: flex; - flex-direction: column; - gap: 1rem; - position: sticky; - top: 1rem; -} - -.sidebar-widget { - background: var(--surfaceElevation1); - border-radius: var(--cornerSmall); - padding: 1rem; -} - -.sidebar-widget h4 { - margin: 0 0 0.75rem; - font-size: 0.875rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.025em; - color: var(--textSecondary); -} - -/* Backup list */ - -.backup-list { - list-style: none; - margin: 0; - padding: 0; - max-height: 25rem; - overflow-y: auto; -} - -.backup-item { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; - padding: 0.375rem 0; - border-bottom: 0.0625rem solid var(--lineDivider); -} - -.backup-item:last-child { - border-bottom: none; -} - -.backup-info { - display: flex; - flex-direction: column; - font-size: 0.8125rem; - line-height: 1.4; -} - -.backup-info time { - font-weight: 500; -} - -.backup-info small { - color: var(--textSecondary); -} - -.backup-item-actions { - display: flex; - gap: 0.25rem; - flex-shrink: 0; -} - -/* Backup detail modal */ - -.backup-detail-section { - margin-bottom: 0.75rem; -} - -.backup-detail-section h4 { - margin: 0 0 0.25rem; - font-size: 0.8125rem; - font-weight: 600; - color: var(--textSecondary); - text-transform: uppercase; - letter-spacing: 0.025em; -} - -.backup-detail-section ul { - list-style: none; - margin: 0; - padding: 0; -} - -.backup-detail-section li { - font-size: 0.8125rem; - padding: 0.125rem 0; -} - -.backup-detail-section .detail-type { - color: var(--textSecondary); - margin-left: 0.25rem; -} - -/* Restore feedback */ - -.restore-feedback { - margin-top: 0.5rem; - font-size: 0.8125rem; - line-height: 1.4; - min-height: 1.25rem; -} - -.feedback-success { - color: var(--successContrast); -} - -.feedback-error { - color: var(--errorContrast); -} diff --git a/static/css/base.css b/static/css/base.css deleted file mode 100644 index e0347682a2..0000000000 --- a/static/css/base.css +++ /dev/null @@ -1,1937 +0,0 @@ -/* Copyright (C) 2026 Percona LLC */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU Affero General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU Affero General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU Affero General Public License */ -/* along with this program. If not, see . */ - -/*=============================================*/ -/* Typography setup */ -/*=============================================*/ - -/* Material Symbols (v3) icons setup */ - -@font-face { - font-family: 'Material Symbols Outlined'; - src: url('/static/fonts/MaterialSymbolsOutlined[FILL,GRAD,opsz,wght].ttf') format('truetype'); - font-weight: normal; - font-style: normal; - font-display: swap; -} - -/* Making icons visible and standardized */ - -.material-symbols-outlined, -.icon::before { - font-family: 'Material Symbols Outlined'; - font-variation-settings: - 'FILL' 0, - 'wght' 400, - 'GRAD' 0, - 'opsz' 24; - font-weight: normal; - font-style: normal; - font-size: 1.5rem; - display: inline-block; - line-height: 1; - text-transform: none; - letter-spacing: normal; - word-wrap: normal; - white-space: nowrap; - direction: ltr; -} - -[data-theme="dark"] .material-symbols-outlined, -[data-theme="dark"] .icon::before { - font-variation-settings: - 'FILL' 0, - 'wght' 300, - 'GRAD' 0, - 'opsz' 24; -} - -/* Roboto typeface setup */ - -@font-face { - font-family: 'Roboto'; - src: url('/static/fonts/Roboto-VariableFont_wdth,wght.ttf') format('truetype'); - font-weight: 100 900; - font-stretch: 75% 125%; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Roboto'; - src: url('/static/fonts/Roboto-Italic-VariableFont_wdth,wght.ttf') format('truetype'); - font-weight: 100 900; - font-stretch: 75% 125%; - font-style: italic; - font-display: swap; -} - -@font-face { - font-family: 'Roboto Mono'; - src: url('/static/fonts/RobotoMono-VariableFont_wght.ttf') format('truetype'); - font-weight: 100 700; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Roboto Mono'; - src: url('/static/fonts/RobotoMono-VariableFont_wght.ttf') format('truetype'); - font-weight: 100 700; - font-style: italic; - font-display: swap; -} - -/* Poppins typeface setup */ - -/* Poppins Semi Bold */ - -@font-face { - font-family: 'Poppins'; - src: url('/static/fonts/Poppins-SemiBold.ttf') format('truetype'); - font-weight: 600; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Poppins'; - src: url('/static/fonts/Poppins-SemiBoldItalic.ttf') format('truetype'); - font-weight: 600; - font-style: italic; - font-display: swap; -} - -/* Poppins Medium */ - -@font-face { - font-family: 'Poppins'; - src: url('/static/fonts/Poppins-Medium.ttf') format('truetype'); - font-weight: 500; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Poppins'; - src: url('/static/fonts/Poppins-MediumItalic.ttf') format('truetype'); - font-weight: 500; - font-style: italic; - font-display: swap; -} - -/*=============================================*/ -/* Custom properties (AKA CSS variables) added globally to serve the remaining CSS */ -/*=============================================*/ - -:root { - /* Primitives */ - --white: #fff; - --black: #000; - - /* Brand */ - --stone50: #f0f1f4; - --stone100: #D1D5DE; - --stone200: #BAC0CD; - --stone300: #9BA4B7; - --stone400: #8792A9; - --stone500: #697793; - --stone600: #606C86; - --stone700: #4B5468; - --stone800: #3A4151; - --stone900: #2C323E; - --sunset50: #FFF2E8; - --sunset100: #FFD7B8; - --sunset200: #FFC496; - --sunset300: #FFA966; - --sunset400: #FF9848; - --sunset500: #FF7E1A; - --sunset600: #E87318; - --sunset700: #B55912; - --sunset800: #8C450E; - --sunset900: #6B350B; - --sky50: #E8F3FF; - --sky100: #B6D9FF; - --sky200: #93C7FF; - --sky300: #62AEFF; - --sky400: #439EFF; - --sky500: #1486FF; - --sky600: #127AE8; - --sky700: #0E5FB5; - --sky800: #0B4A8C; - --sky900: #08386B; - - /* Extra */ - --green50: #E7F6F1; - --green100: #B6E3D6; - --green200: #85CFBC; - --green300: #51BAA2; - --green400: #00A489; - --green500: #008C71; - --green600: #00745B; - --green700: #005C45; - --green800: #004430; - --green900: #002C1D; - --red50: #FFECE9; - --red100: #FFC6BE; - --red200: #FEA195; - --red300: #F37C6F; - --red400: #E2584D; - --red500: #CC352E; - --red600: #B10810; - --red700: #920000; - --red800: #700000; - --red900: #4D0000; - - /* Elevations / Drop shadows */ - --elevation1: 0 0 0 0.0625rem rgba(0, 0, 0, 0.06); - --elevation2: 0 0.1875rem 0.0625rem -0.125rem rgba(0, 0, 0, 0.20), 0 0.125rem 0.125rem 0 rgba(0, 0, 0, 0.14), 0 0.0625rem 0.3125rem 0 rgba(0, 0, 0, 0.12); - --elevation4: 0 0.125rem 0.25rem -0.0625rem rgba(0, 0, 0, 0.20), 0 0.25rem 0.3125rem 0 rgba(0, 0, 0, 0.14), 0 0.0625rem 0.625rem 0 rgba(0, 0, 0, 0.12); - --elevation8: 0 0.3125rem 0.3125rem -0.1875rem rgba(0, 0, 0, 0.20), 0 0.5rem 0.625rem 0.0625rem rgba(0, 0, 0, 0.14), 0 0.1875rem 0.875rem 0.125rem rgba(0, 0, 0, 0.12); - --elevation12: 0 0.4375rem 0.5rem -0.25rem rgba(0, 0, 0, 0.20), 0 0.75rem 1.0625rem 0.125rem rgba(0, 0, 0, 0.14), 0 0.3125rem 1.375rem 0.25rem rgba(0, 0, 0, 0.12); - --elevation24: 0 0.6875rem 0.9375rem -0.4375rem rgba(0, 0, 0, 0.20), 0 1.5rem 2.375rem 0.1875rem rgba(0, 0, 0, 0.14), 0 0.5625rem 2.875rem 0.5rem rgba(0, 0, 0, 0.12); - - /* Corners */ - --cornerExtraSmall: 0.25rem; - --cornerSmall: 0.5rem; - --cornerMedium: 0.75rem; - --cornerLarge: 1rem; - --cornerExtraLarge: 1.75rem; - --cornerFull: 100rem; -} - -/* The themes setup */ - -/* Light theme is set by default on */ - -[data-theme="light"] { - color-scheme: light; - - /* Basis */ - --surfaceElevation0: var(--stone50); - --surfaceElevation1: var(--white); - --surfaceElevation2: var(--stone100); - --surfaceBackdrop: rgba(44, 50, 62, 0.72); - --textPrimary: #2C323E; - --textSecondary: rgba(44, 50, 62, 0.72); - --textDisabled: rgba(44, 50, 62, 0.40); - - /* Lines */ - --lineDivider: rgba(44, 50, 62, 0.25); - --lineDividerStrong: rgba(44, 50, 62, 0.50); - --lineDividerStronger: #2C323E; - --lineContour: rgba(0, 0, 0, 0.06); - --lineFocus: var(--sky400); - - /* Primary */ - --primaryMain: var(--sunset600); - --primaryLight: var(--sunset500); - --primaryDark: var(--sunset700); - --primaryContrast: var(--white); - --primaryHover: rgba(220, 63, 0, 0.04); - --primarySelected: rgba(220, 63, 0, 0.08); - --primaryFocus: rgba(220, 63, 0, 0.12); - - /* Action */ - --actionHover: rgba(44, 50, 62, 0.04); - --actionDisabled: rgba(44, 50, 62, 0.12); - --actionFocus: rgba(44, 50, 62, 0.12); - - /* Success */ - --successMain: var(--green600); - --successLight: var(--green500); - --successDark: var(--green700); - --successContrast: var(--successDark); - --successSurface: var(--green50); - - /* Warning */ - --warningMain: var(--sunset600); - --warningLight: var(--sunset500); - --warningDark: var(--sunset700); - --warningContrast: var(--warningDark); - --warningSurface: var(--sunset50); - - /* Info */ - --infoMain: var(--sky600); - --infoLight: var(--sky500); - --infoDark: var(--sky700); - --infoContrast: var(--infoDark); - --infoSurface: var(--sky50); - - /* Error */ - --errorMain: var(--red600); - --errorLight: var(--red500); - --errorDark: var(--red700); - --errorContrast: var(--errorDark); - --errorSurface: var(--red50); - - /* Stopped */ - --stoppedContrast: var(--stone700); - --stoppedSurface: var(--stone100); - - /* Lost */ - --lostContrast: var(--sunset700); - --lostSurface: var(--sunset100); - - /* Stale */ - --staleContrast: var(--warningContrast); - --staleSurface: var(--warningSurface); -} - -/* Dark theme is set up to switch using element and JS to swap the theme name on [data-theme] attribute */ - -[data-theme="dark"] { - color-scheme: dark; - - /* Basis */ - --surfaceElevation0: var(--stone900); - --surfaceElevation1: var(--stone800); - --surfaceElevation2: var(--stone700); - --surfaceBackdrop: rgba(0, 0, 0, 0.72); - --textPrimary: #FBFBFB; - --textSecondary: rgba(251, 251, 251, 0.72); - --textDisabled: rgba(251, 251, 251, 0.40); - - /* Lines */ - --lineDivider: rgba(255, 255, 255, 0.25); - --lineDividerStrong: rgba(255, 255, 255, 0.50); - --lineDividerStronger: #fff; - --lineContour: rgba(255, 255, 255, 0.08); - --lineFocus: var(--sky600); - - /* Primary */ - --primaryMain: var(--sunset300); - --primaryLight: var(--sunset200); - --primaryDark: var(--sunset400); - --primaryContrast: var(--black); - --primaryHover: rgba(245, 106, 51, 0.08); - --primarySelected: rgba(245, 106, 51, 0.12); - --primaryFocus: rgba(245, 106, 51, 0.15); - - /* Action */ - --actionHover: rgba(240, 241, 244, 0.08); - --actionDisabled: rgba(240, 241, 244, 0.15); - --actionFocus: rgba(240, 241, 244, 0.15); - - /* Success */ - --successMain: var(--green400); - --successLight: var(--green300); - --successDark: var(--green500); - --successContrast: var(--white); - --successSurface: var(--successDark); - - /* Warning */ - --warningMain: var(--sunset400); - --warningLight: var(--sunset300); - --warningDark: var(--sunset500); - --warningContrast: var(--white); - --warningSurface: var(--warningDark); - - /* Info */ - --infoMain: var(--sky400); - --infoLight: var(--sky300); - --infoDark: var(--sky500); - --infoContrast: var(--white); - --infoSurface: var(--infoDark); - - /* Error */ - --errorMain: var(--red300); - --errorLight: var(--red200); - --errorDark: var(--red400); - --errorContrast: var(--white); - --errorSurface: var(--errorDark); - - /* Stopped */ - --stoppedContrast: var(--white); - --stoppedSurface: var(--stone500); - - /* Lost */ - --lostContrast: var(--white); - --lostSurface: var(--sunset500); - - /* Stale */ - --staleContrast: var(--warningContrast); - --staleSurface: var(--warningSurface); -} - -body[data-theme="dark"] .theme-change.dark, -body[data-theme="light"] .theme-change.light { - display: none; -} - -/*=============================================*/ -/* Setting the stage and the layout */ -/*=============================================*/ - -html { - font-size: 100%; -} - -html:not(.fonts-loaded) body { - opacity: 0; -} - -html.fonts-loaded body { - opacity: 1; -} - -html, -body { - height: 100%; - margin: 0; - overflow: hidden; -} - -body { - display: grid; - grid-template-rows: auto 1fr; - font-family: 'Roboto', sans-serif; - font-weight: 400; - font-size: 1em; - line-height: 1.375; - margin: 0; - color: var(--textPrimary); - background: var(--surfaceElevation0); - transition: opacity .05s ease-out .05s; -} - -body[data-theme="dark"] { - font-weight: 350; -} - -/*=============================================*/ -/* Typography styling */ -/*=============================================*/ - -h1, -h2, -h3, -h4, -h5, -h6 { - font-family: 'Poppins', sans-serif; - font-weight: 600; - line-height: 1.0625; - margin: 0.75em 0 0.25em; -} - -h1 { - margin-top: 0 !important; -} - -h1, -.h1 { - font-size: 3em; - letter-spacing: -0.03125em; -} - -h2, -.h2 { - font-size: 2.5em; - letter-spacing: -0.015625em; -} - -h3, -.h3, -.app-content h1, -.app-content h2 { - font-size: 2.0625em; -} - -h4, -.h4 { - font-size: 1.75em; -} - -h5, -.h5 { - font-size: 1.4375em; - line-height: 1.125; -} - -h6, -.h6 { - font-size: 1.1875em; - line-height: 1.25; -} - -p { - line-height: 1.375em; - margin: 0 0 1.375em; -} - -sup { - margin-top: -0.5em; -} - -pre { - font-family: 'Roboto Mono', monospace; - font-size: 1em; - line-height: 1.25; - margin: 0; -} - -.soft-wrap { - white-space: pre-wrap; - word-wrap: break-word; -} - -/*=============================================*/ -/* Brand assets */ -/*=============================================*/ - -#perconaLogo #wordmark { - fill: var(--textPrimary); -} - -#githubLogo { - fill: currentColor; -} - -/*=============================================*/ -/* Interaction touchpoints like links and buttons */ -/*=============================================*/ - -:focus-visible, -input[type="checkbox"]:focus-visible~.checkmark { - outline: 0.1875rem solid var(--lineFocus); - outline-offset: 0.125rem; -} - -/* Links */ - -a { - color: inherit; - cursor: pointer; - transition: opacity .2s, color .2s, outline .1s; -} - -a:link { - color: var(--primaryMain); - text-decoration: underline; -} - -a:visited { - color: var(--primaryMain); -} - -a:hover, -a:active { - color: var(--primaryLight); -} - -/* Buttons */ - -/* Base styling */ - -button { - font-family: 'Poppins', sans-serif; - font-size: 0.8125em; - font-weight: 600; - line-height: 1.0625; - display: flex; - justify-content: center; - align-items: center; - gap: 0.25rem; - background: none; - border: none; - border-radius: var(--cornerFull); - cursor: pointer; - color: var(--textPrimary) -} - -button[disabled] { - color: var(--textDisabled); - box-shadow: none; - cursor: not-allowed; -} - -/* Base sizes */ - -button.large { - font-size: 0.9375em; - padding: 1rem 1.5rem; -} - -button.medium { - padding: 0.6875rem 1rem; - min-width: 6rem; -} - -button.small { - padding: 0.5rem 0.9375rem; -} - -/* Button Contained variant */ - -button.contained { - color: var(--primaryContrast); - background: var(--primaryMain); - box-shadow: var(--elevation2); - transition: background .2s, box-shadow .2s; -} - -button.contained:hover:not([disabled]), -button.contained:active:not([disabled]), -button.contained:focus:not([disabled]) { - background: var(--primaryDark); - box-shadow: var(--elevation4); -} - -button.contained[disabled] { - background: var(--actionDisabled); -} - -/* Button Outlined variant */ - -button.outlined { - color: var(--primaryMain); - border: 0.125rem solid var(--primaryMain); - transition: border .2s, color .2s, background .2s; -} - -button.outlined:hover:not([disabled]), -button.outlined:active:not([disabled]), -button.outlined:focus:not([disabled]) { - color: var(--primaryDark); - background: var(--primaryHover); - border-color: var(--primaryDark); -} - -button.outlined:focus:not([disabled]) { - background: var(--primaryFocus); -} - -button.outlined[disabled] { - color: var(--textDisabled); - border-color: var(--textDisabled); -} - -button.text { - color: var(--primaryMain); - transition: background .2s, color .2s; -} - -button.text.large { - padding: 0.5rem 0.6875rem; -} - -button.text.medium { - padding: 0.375rem 0.5rem; -} - -button.text.small { - padding: 0.25rem 0.3125rem; -} - -button.text:hover:not([disabled]), -button.text:active:not([disabled]), -button.text:focus:not([disabled]) { - color: var(--primaryDark); - background: var(--primaryHover); -} - -button.text[disabled] { - color: var(--textDisabled); -} - -/* Button Icon variant */ - -button.icon { - color: var(--textPrimary); - transition: background .2s, color .2s; -} - -button.icon:hover:not([disabled]), -button.icon:active:not([disabled]), -button.icon:focus:not([disabled]) { - color: var(--primaryMain); - background: var(--primaryHover); -} - -button.icon.large { - padding: 0.75rem; -} - -button.icon.large .material-symbols-outlined { - font-size: 2.5rem; -} - -button.icon.medium { - padding: 0.5rem; -} - -button.icon.medium .material-symbols-outlined { - font-size: 1.5rem; -} - -button.icon.small { - padding: 0.6875rem; -} - -button.icon.small .material-symbols-outlined { - font-size: 1.25rem; -} - -button.icon[disabled] { - color: var(--textDisabled); -} - -/* Interactivity behaviors and utility classes */ - -[aria-hidden="true"], -.visually-hidden { - display: none; -} - -[aria-hidden="false"] { - display: block; -} - -.flex { - display: flex; -} - -.text-align-right { - text-align: right !important; -} - -/* Tabs */ -/* Layout */ - -.tabs { - display: flex; - flex-wrap: wrap; - border-bottom: 0.0625rem solid var(--lineDivider); -} - -.tabs-container.card { - padding: 1em; - outline: 0.0625rem solid var(--lineDivider); - border-radius: var(--cornerSmall); - background: var(--surfaceElevation1); -} - -/* Button Tab variant */ - -button[role="tab"], -button[role="log-tab"] { - display: flex; - width: fit-content; - gap: 0.375rem; - min-height: 3rem; - padding: 0.5rem 1rem 0.25rem; - border-bottom: 0.25rem solid transparent; - border-radius: 0; - color: var(--textSecondary); - transition: background .2s, color .2s, border-color .2s; -} - -button[role="tab"]:hover, -button[role="log-tab"]:hover { - color: var(--primaryLight); - background: var(--primaryHover); -} - -button[role="tab"][aria-selected="true"], -button[role="log-tab"][aria-selected="true"] { - color: var(--textPrimary); - background: none; - border-color: var(--primaryMain); - cursor: default; -} - -button[role="tab"][aria-selected="true"]:focus-visible, -button[role="log-tab"][aria-selected="true"]:focus-visible { - outline-style: dashed; -} - -dialog div.window footer button { - width: fit-content; -} - -/* Inputs global */ - -label+input { - margin-top: 0.5em; -} - -input[type="search"], -input[type="datetime-local"], -select, -.datatable-selector { - font-family: 'Roboto', sans-serif; - font-weight: 400; - font-size: 1em; - line-height: 1.5; - box-sizing: border-box; - height: 2.5em; - padding: 0.375em 0.75em; - border: 0.125rem solid var(--lineDivider); - border-radius: var(--cornerExtraSmall); - color: var(--textPrimary); - background: var(--surfaceElevation1); - transition: border-color .1s; -} - -input[type="search"]:hover, -input[type="datetime-local"]:hover, -select:not(:disabled):hover { - border-color: var(--lineDividerStrong); - cursor: pointer; -} - -input[type="search"]:focus, -input[type="datetime-local"]:focus, -select:focus { - border-color: var(--lineDividerStronger); -} - -select { - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - width: -webkit-fit-content; - width: -moz-fit-content; - width: fit-content; -} - -select[multiple] { - display: flex; - flex-direction: column; - gap: 0.25em; - height: fit-content; - max-height: 10em; - padding: 0.25em; -} - -select[multiple] option { - padding: 0.25em 0.5em; - border-radius: var(--cornerExtraSmall); -} - -select[multiple] option+option { - margin-top: 0.25em; -} - -select:disabled { - color: var(--textDisabled); - background: var(--actionDisabled); -} - -.input .material-symbols-outlined, -.select .material-symbols-outlined { - font-size: 1.5rem; - pointer-events: none; -} - -.input, -.select>* { - display: inline-block; - position: relative; - max-width: 100%; -} - -.input.icon-left .material-symbols-outlined { - position: absolute; - top: 0.5rem; - left: 0.75rem; -} - -.select .material-symbols-outlined, -.input.icon-right .material-symbols-outlined { - position: absolute; - top: 0.5rem; - right: 0.75rem; -} - -.select select:not([multiple]), -.input.icon-right { - padding-right: 2.625em; -} - -.input.icon-left input { - padding-left: 2.625em; -} - -.select .label { - color: var(--textSecondary); - pointer-events: none; -} - -.checkbox { - display: flex; - flex-wrap: wrap; - align-items: center; - user-select: none; - cursor: pointer; -} - -.checkbox.disabled { - cursor: not-allowed; - opacity: 0.5; -} - -input[type="checkbox"] { - opacity: 0; - height: 0; - width: 0; - cursor: pointer; -} - -.checkmark { - display: flex; - justify-content: center; - align-items: center; - width: 2.5rem; - height: 2.5rem; - border-radius: var(--cornerFull); - transition: background .2s; -} - -.checkbox:not(.disabled):hover .checkmark { - background: var(--primaryHover); -} - -.checkmark .checked { - color: var(--primaryMain); -} - -input[type="checkbox"]:focus-visible~.checkmark { - background: var(--primaryFocus); - outline-offset: -0.1875rem; -} - -input[type="checkbox"]~.checkmark .checked, -input[type="checkbox"]:checked~.checkmark .unchecked { - display: none; -} - -input[type="checkbox"]~.checkmark .unchecked, -input[type="checkbox"]:checked~.checkmark .checked { - display: block; -} - -/*=============================================*/ -/* Header */ -/*=============================================*/ - -header.topnav { - position: -webkit-sticky; - position: sticky; - display: flex; - justify-content: space-between; - flex-wrap: wrap; - gap: 0.25rem; - top: 0; - z-index: 2; - padding: 0.75em; - background: var(--surfaceElevation1); - outline: 0.0625rem solid var(--lineDivider); -} - -.app-logo { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.5rem; - padding: 0.25em; -} - -.app-logo:hover { - opacity: 0.7; -} - -a.app-logo:link, -a.app-logo:visited, -a.app-logo:hover, -a.app-logo:active { - color: var(--primaryMain); - text-decoration: none; -} - -.app-logo svg { - height: 1.75em; -} - -.app-logo .label { - font-family: 'Poppins', sans-serif; - font-weight: 600; - font-size: 1.0625em; -} - -.app-logo .label.acronym { - display: none; -} - -.app-breadcrumbs { - font-family: 'Poppins', sans-serif; - font-weight: 500; - font-size: 0.8125em; - line-height: 1.0625; - flex: 1 0 100%; - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.5rem; - padding: 0.25rem; - color: var(--textSecondary); - transition: -webkit-text-decoration .2s; - transition: text-decoration .2s; - transition: text-decoration .2s, -webkit-text-decoration .2s; -} - -.app-breadcrumbs>* { - flex-grow: 0; -} - -.app-breadcrumbs a:link { - text-decoration: none; - transition: -webkit-text-decoration .2s; - transition: text-decoration .2s; - transition: text-decoration .2s, -webkit-text-decoration .2s; -} - -.app-breadcrumbs a:hover, -.app-breadcrumbs a:active { - text-decoration: underline; -} - -.app-breadcrumbs .separator { - font-size: 1.25rem; -} - -.app-nav { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 1rem; -} - -@media (max-width: 560px) { - .app-logo .label { - display: none; - } - - .app-logo .label.acronym { - display: unset; - } -} - -/*=============================================*/ -/* Menu component */ -/*=============================================*/ - -.menu .content { - position: absolute; - top: 0; - left: 0; - width: 16em; - max-width: calc(100vw - 2em); - background: var(--surfaceElevation1); - border-radius: var(--cornerExtraSmall); - outline: 0.0625rem solid var(--lineContour); - box-shadow: var(--elevation8); - z-index: 13; -} - -.menu ul { - list-style: none; - padding: 0.5em 0; - margin: 0; -} - -.menu ul:not(:first-of-type) { - border-top: 0.0625rem solid var(--lineDivider); -} - -/*=============================================*/ -/* Tooltip */ -/*=============================================*/ - -.tooltip { - font-size: 0.875em; - text-align: center; - max-width: 15rem; - padding: 0.375em 0.5em; - color: var(--stone50); - background: var(--stone900); - border: 0.0625rem solid var(--lineDivider); - border-radius: var(--cornerExtraSmall); - box-shadow: var(--elevation4); -} - -/*=============================================*/ -/* Modal overlay dialog */ -/*=============================================*/ - -dialog { - position: relative; - text-align: initial; - color: var(--textPrimary); - background: var(--surfaceElevation1); - width: calc(100vw - 2em); - height: -webkit-fit-content; - height: -moz-fit-content; - height: fit-content; - max-height: calc(100vh - 2em); - padding: 0; - border: 0.0625rem solid var(--lineDivider); - border-radius: var(--cornerSmall); - box-shadow: var(--elevation24); - z-index: 34; -} - -dialog.large { - max-width: 50em; -} - -dialog.medium { - max-width: 40em; -} - -dialog.small { - max-width: 30em; -} - -dialog.fullwidth { - height: 87.5vh; - max-width: 100vw; -} - -dialog::-webkit-backdrop { - background: var(--surfaceBackdrop); -} - -dialog::backdrop { - background: var(--surfaceBackdrop); -} - -dialog .window { - box-sizing: border-box; - display: flex; - flex-direction: column; - width: 100%; - height: 100%; -} - -dialog header, -dialog section, -dialog footer { - display: flex; - flex-wrap: wrap; -} - -dialog header, -dialog footer { - flex: 0 0 auto; -} - -dialog header { - justify-content: space-between; - gap: 0.5em; - padding: 1em 1em 0; -} - -dialog footer { - justify-content: flex-end; - gap: 0.5em; - padding: 1em; -} - -dialog section { - flex: 1 1 auto; - box-sizing: border-box; - flex-direction: column; - padding: 1em; - overflow-y: auto; -} - -input:disabled { - display: none; -} - -dialog .title { - margin: 0; -} - -dialog section> :last-child { - margin-bottom: 0; -} - -dialog button[role="tab"]:focus-visible, -dialog button[role="log-tab"]:focus-visible { - outline-offset: -0.1875rem; -} - -/*=============================================*/ -/* Interactive List Item for menus and other navigation lists */ -/*=============================================*/ - -a.list-item, -button.list-item { - font-size: 1em; - text-align: left; - display: flex; - align-items: center; - gap: 0.5rem; - width: 100%; - padding: 0.5rem 1rem; - border-radius: 0; - box-sizing: border-box; - color: var(--textPrimary); - text-decoration: none; - transition: background .2s, color .2s; - cursor: pointer; -} - -a.list-item:hover, -button.list-item:hover { - color: var(--primaryLight); - background: var(--primaryHover); -} - -a.list-item.active, -button.list-item.active { - color: var(--primaryMain); - background: var(--primarySelected); -} - -a.list-item:focus-visible, -button.list-item:focus-visible { - outline-offset: -0.1875rem; -} - -.list-item .label { - flex: 1 1 auto; - font-family: 'Poppins', sans-serif; - font-weight: 600; - font-size: 0.875em; - line-height: 1.375; -} - -.list-item .icon::before { - display: block; -} - -.list-item.alters .icon::before { - content: "table_edit"; -} - -.list-item.archive .icon::before { - content: "archive"; -} - -.list-item.backups .icon::before { - content: "history"; -} - -.list-item.mysql_backups .icon::before { - content: "history"; -} - -.list-item.restores .icon::before { - content: "refresh"; -} - -.list-item.checksums .icon::before { - content: "new_releases"; -} - -.list-item.backup_mongo .icon::before { - content: "nature"; -} - -.list-item.backup_pg .icon::before { - content: "backup"; -} - -.list-item.inventory .icon::before { - content: "graph_4"; -} - -.list-item.tasks .icon::before { - content: "assignment_turned_in"; -} - -.list-item.snippets .icon::before { - content: "snippet_folder"; -} - -.list-item.atw .icon::before { - content: "support"; -} - -.list-item.dipper .icon::before { - content: "science"; -} - -.list-item.alerts .icon::before { - content: "notifications"; -} - -.list-item.alert-troubleshooting .icon::before { - content: "troubleshoot"; -} - -.list-item.report .icon::before { - content: "assignment"; -} - -/*=============================================*/ -/* Foundations for the sidebar type of layout */ -/*=============================================*/ - -.sidebar-layout { - display: flex; - align-items: stretch; - overflow: hidden; -} - -.sidebar { - position: -webkit-sticky; - position: sticky; - flex: 0 0 16em; - display: flex; - flex-direction: column; - justify-content: space-between; - top: 0; - z-index: 1; - overflow-y: auto; - padding: 0.5em 0; -} - -.app-content { - flex: 1 1 auto; - padding: 1em; - overflow-y: auto; -} - -.vr { - width: 0.0625rem; - background: var(--lineDivider); - margin: 1em 0; -} - -/* Sidebar component */ - -.sidebar ul { - list-style: none; - padding: 0.5em 0; - margin: 0; -} - -footer { - padding: 0 1em; -} - -footer ul { - display: flex; - flex-direction: column; - gap: 0.5em; -} - -footer li { - font-size: 0.875em; -} - -footer .link { - display: flex; - gap: 0.25rem; -} - -footer a:link { - text-decoration: none; -} - -footer a:link .label { - text-decoration: underline; -} - -footer .link .material-symbols-outlined { - font-size: 1.25rem; -} - -footer .link svg { - width: 1.25rem; - height: 1.125rem; -} - -/*=============================================*/ -/* Messaging components */ -/*=============================================*/ - -/* Badge */ - -.status .badge { - vertical-align: -0.25rem; - font-size: 1.25em; - padding: 0.1875rem; - border: 0.0625rem solid var(--lineContour); - border-radius: var(--cornerFull); -} - -.status .success { - color: var(--successContrast); -} - -.status .error, -.status .failed { - color: var(--errorContrast); -} - -.status .stopped { - color: var(--stoppedContrast); -} - -.status .lost { - color: var(--lostContrast); -} - -.status .stale { - color: var(--staleContrast); -} - -.success .badge { - background: var(--successSurface); -} - -.error .badge, -.failed .badge { - background: var(--errorSurface); -} - -.stopped .badge { - background: var(--stoppedSurface); -} - -.lost .badge { - background: var(--lostSurface); -} - -.stale .badge { - background: var(--staleSurface); -} - -/*=============================================*/ -/* Global styling for plugins apps */ -/*=============================================*/ - -.app-intro { - max-width: 50em; - padding: 0.375em 1em 1em; -} - -.app-intro :first-child { - margin-top: 0; -} - -.app-intro :last-child { - margin-bottom: 0; -} - -.app-stage>.tabs { - margin: 0 1em 0.0625em; - border: none; -} - -/*=============================================*/ -/* Simple-DataTables styling and overrides */ -/*=============================================*/ - -/* Simple-DataTables layout and global */ - -.datatable-container { - overflow-x: auto; - -webkit-overflow-scrolling: touch; - width: 100%; -} - -.datatable-table { - width: auto; - min-width: 100%; -} - -.datatable-top::after, -.datatable-bottom::after { - display: none; -} - -.datatable-top, -.datatable-bottom { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - gap: 1.5em; - padding: 0.5em; -} - -.datatable-top { - padding-bottom: 1.5em; -} - -.datatable-bottom { - padding-top: 1em; -} - -.datatable-wrapper.no-footer .datatable-container { - border-top: 0.0625rem solid var(--lineDivider); - border-bottom: 0.0625rem solid var(--lineDivider); -} - -tbody tr:not(:last-child) td { - border-bottom: 0.0625rem solid var(--lineDivider); -} - -/* Simple-DataTables header components */ - -.datatable-search .datatable-input { - width: 20em; - max-width: 100%; -} - -.datatable-dropdown .datatable-selector { - margin-left: 0.25em; -} - -/* Simple-DataTables footer components */ - -.datatable-info { - color: var(--textSecondary); - padding: 0.5625em 0; - margin: 0; -} - -.datatable-pagination-list { - display: flex; - flex-wrap: wrap; -} - -.datatable-pagination-list-item:first-child, -.datatable-pagination-list-item:last-child { - order: 2; -} - -.datatable-pagination li.datatable-hidden { - visibility: visible; -} - -.datatable-pagination-list button { - display: block; - font-family: 'Poppins', sans-serif; - font-weight: 600; - font-size: 0.9375em; - line-height: 1.0625; - text-align: center; - min-width: 2.5rem; - min-height: 2.5rem; - padding: 0.5rem 0.6875rem; - margin: 0; - color: var(--textSecondary); - background: none; - transition: background .2s, color .2s; -} - -.datatable-pagination-list button:hover { - color: var(--primaryLight); - background: var(--primaryHover); -} - -.datatable-pagination .datatable-active a, -.datatable-pagination .datatable-active a:focus, -.datatable-pagination .datatable-active a:hover, -.datatable-pagination .datatable-active button, -.datatable-pagination .datatable-active button:focus, -.datatable-pagination .datatable-active button:hover { - color: var(--primaryMain); - background: var(--primarySelected); - transition: background .2s; -} - -.datatable-pagination-list-item:last-child .datatable-pagination-list-item-link, -.datatable-pagination-list-item:first-child .datatable-pagination-list-item-link { - display: flex; - font-family: 'Material Symbols Outlined'; - font-weight: 400; - font-size: 1.5em; - width: 2.5rem; - height: 2.5rem; -} - -/* Simple-DataTables table styling */ - -.datatable-table>thead>tr>th { - border-bottom: 0.0625rem solid var(--lineDivider); -} - -.datatable-table>tbody>tr>th, -.datatable-table>tfoot>tr>th, -.datatable-table>thead>tr>th, -.datatable-sorter { - font-weight: 500; - line-height: 1.25; - padding: 0.875em 0.5em; - vertical-align: middle; -} - -.datatable-table>tbody>tr>th[data-sortable="true"], -.datatable-table>tfoot>tr>th[data-sortable="true"], -.datatable-table>thead>tr>th[data-sortable="true"] { - padding: 0; -} - -.datatable-table>tbody>tr>td, -.datatable-table>tfoot>tr>td, -.datatable-table>thead>tr>td { - line-height: 1.375; - padding: 0.8125em 0.5em; -} - -.datatable-sorter { - display: flex; - justify-content: flex-start; - align-items: center; - gap: 0.25em; - border-radius: 0; - transition: background .2s, color .2s; -} - -.datatable-sorter:hover { - color: var(--primaryLight); - background: var(--primaryHover); -} - -.datatable-sorter:focus-visible { - outline-offset: -0.1875rem; -} - -.datatable-sorter::before, -.datatable-sorter::after { - position: initial; - display: none; - order: 2; - font-family: 'Material Symbols Outlined'; - font-weight: 400; - font-size: 1.25rem; - line-height: 1; - height: auto; - width: auto; - top: auto; - right: auto; - bottom: auto; - max-height: 2.5rem; - border: none; - opacity: 1; -} - -.datatable-sorter::before { - content: "south"; -} - -.datatable-sorter::after { - content: "north"; -} - -.datatable-ascending .datatable-sorter::after, -.datatable-descending .datatable-sorter::before, -.datatable-ascending .datatable-filter::after, -.datatable-descending .datatable-filter::before { - display: flex; - opacity: 1; -} - -/* Simple-DataTables SEP styling flavors */ - -.datatable-table>thead>tr>th.options, -.datatable-table>tbody>tr>td.options { - text-align: right; - padding-top: 0.25em; - padding-bottom: 0.25em; -} - -.datatable-table>thead>tr>th.number, -.datatable-table>tbody>tr>td.number { - text-align: right; -} - -th.options .datatable-sorter, -th.number .datatable-sorter { - justify-content: flex-end; -} - -button.icon:focus-visible { - outline-offset: 0; -} - -th.options>*:not(dialog), -td.options>*:not(dialog) { - display: inline-flex; -} - -.datatable-table>tbody>tr>td.code { - font-family: 'Roboto Mono', monospace; - font-size: 1em; - line-height: 1; - padding-top: 1em; - padding-bottom: 1em; -} - -.datatable-table>tbody>tr>td.status { - padding: 0.625em 0.5em; -} - -td.errors ul { - padding: 0 0 0 1.375em; - margin: 0; -} - -/*=============================================*/ -/* Log Containers */ -/*=============================================*/ - -.modal-log header { - flex-direction: column; - padding: 0; -} - -.modal-log header .title { - padding: 1rem 1rem 0; -} - -.modal-log header .tabs { - align-items: center; - flex-wrap: wrap-reverse; -} - -.modal-log header .checkbox { - order: 3; - margin: 0 1rem 0 auto; -} - -.modal-log footer { - border-top: 0.0625rem solid var(--lineDivider); -} - -.log-content { - width: 100%; - color: var(--textPrimary); - background: var(--surfaceElevation0); -} - -.log-content>* { - width: 100%; -} - -/* Monospace like
; headings are h4/h5 so override global Poppins on h4–h6 */
-.log-events-view {
-    box-sizing: border-box;
-    width: 100%;
-    min-height: 12rem;
-    padding: 0.75rem 1rem 1rem;
-    overflow: auto;
-    font-family: 'Roboto Mono', ui-monospace, monospace;
-    font-size: 1em;
-    line-height: 1.4;
-    color: var(--textPrimary);
-    background: var(--surfaceElevation0);
-}
-
-.log-events-view__title {
-    margin: 0 0 0.75rem;
-    font-size: 1em;
-    font-weight: 600;
-    font-family: 'Roboto Mono', ui-monospace, monospace;
-}
-
-.log-events-view h4.log-events-view__title,
-.log-events-view h5.log-events-step__title {
-    font-family: 'Roboto Mono', ui-monospace, monospace;
-}
-
-.log-events-step {
-    margin: 0 0 1rem;
-    padding: 0 0 0.5rem;
-}
-
-.log-events-step:last-child {
-    border-bottom: none;
-    margin-bottom: 0;
-}
-
-.log-events-step__title {
-    margin: 0 0 0.5rem;
-    font-size: 1em;
-    font-weight: 600;
-    font-family: 'Roboto Mono', ui-monospace, monospace;
-}
-
-.log-events-step__list {
-    margin: 0;
-    padding: 0;
-    list-style: none;
-}
-
-.log-events-step__item {
-    margin: 0 0 0.65rem;
-}
-
-.log-events-step__time {
-    display: block;
-    margin-bottom: 0.15rem;
-    opacity: 0.88;
-    font-size: 0.875em;
-    font-family: inherit;
-}
-
-.log-events-step__body {
-    white-space: pre-wrap;
-    word-break: break-word;
-}
-
-.log-events-step-panels .log-events-step-panel {
-    display: none;
-}
-
-.log-events-step-missing {
-    display: none;
-    margin: 0.5rem 0 0;
-    padding: 0.75rem 0;
-    opacity: 0.85;
-    font-family: inherit;
-}
-
-.sep-stream-error {
-    box-sizing: border-box;
-    padding: 1rem 1.25rem;
-    margin-bottom: 0.75rem;
-    border-radius: var(--cornerExtraSmall, 0.25rem);
-    border: 0.0625rem solid var(--lineDivider, #ccc);
-    background: var(--surfaceElevation1, #f5f5f5);
-    font-family: 'Poppins', sans-serif;
-    font-size: 0.875rem;
-    line-height: 1.45;
-}
-
-.sep-stream-error--gone {
-    border-color: var(--errorMain, #c62828);
-    background: color-mix(in srgb, var(--errorMain, #c62828) 8%, var(--surfaceElevation0, #fff));
-}
-
-.sep-stream-error__summary {
-    margin: 0 0 0.5rem;
-    font-weight: 600;
-    color: var(--textPrimary, #1a1a1a);
-}
-
-.sep-stream-error__meta {
-    margin: 0.25rem 0 0;
-    padding-left: 1.25rem;
-    color: var(--textSecondary, #444);
-}
-
-.sep-stream-error__meta li {
-    margin: 0.2em 0;
-}
-
-.sep-stream-error__nomad,
-.sep-stream-error__raw {
-    margin: 0.5rem 0 0;
-    font-size: 0.8125rem;
-    color: var(--textSecondary, #555);
-    white-space: pre-wrap;
-    word-break: break-word;
-}
-
-.sep-stream-error__raw {
-    margin: 0;
-    font-family: ui-monospace, monospace;
-}
-
-.log-tabs {
-    display: flex;
-    flex-wrap: wrap;
-    gap: 0.5em;
-}
-
-/*=============================================*/
-/* Toggle Buttons */
-/*=============================================*/
-
-.toggle-btn-group {
-    display: inline-flex;
-    border: 0.0625rem solid var(--lineDivider);
-    border-radius: var(--cornerExtraSmall);
-    overflow: hidden;
-    margin-bottom: 1.5rem;
-    background-color: var(--surfaceElevation0);
-}
-
-.toggle-btn {
-    font-family: 'Poppins', sans-serif;
-    font-weight: 500;
-    font-size: 0.9375em;
-    padding: 0.5em 1.25em;
-    text-align: center;
-    text-decoration: none;
-    color: var(--textSecondary);
-    background: var(--surfaceElevation1);
-    border-right: 0.0625rem solid var(--lineDivider);
-    transition: background 0.2s, color 0.2s;
-}
-
-.toggle-btn:last-child {
-    border-right: none;
-}
-
-.toggle-btn:hover {
-    background: var(--primaryHover);
-    color: var(--primaryLight);
-}
-
-.toggle-btn.active {
-    background: var(--primarySelected);
-    color: var(--primaryMain);
-    cursor: default;
-}
-
-.toggle-btn-group .toggle-btn {
-    text-decoration: none;
-}
-
-/*=============================================*/
-/* Sticky Header */
-/*=============================================*/
-.sticky-header {
-    position: sticky;
-    top: 0;
-    z-index: 10;
-}
-
-.unselectable {
-    -webkit-touch-callout: none;
-    -webkit-user-select: none;
-    -khtml-user-select: none;
-    -moz-user-select: none;
-    -ms-user-select: none;
-    user-select: none;
-}
-
-.iconTextLine>* {
-    vertical-align: middle;
-}
-
-.hidden {
-    display: none;
-}
diff --git a/static/css/chain-builder.css b/static/css/chain-builder.css
deleted file mode 100644
index 962a29273c..0000000000
--- a/static/css/chain-builder.css
+++ /dev/null
@@ -1,189 +0,0 @@
-/* Copyright (C) 2026 Percona LLC                                               */
-/*                                                                              */
-/* This program is free software: you can redistribute it and/or modify         */
-/* it under the terms of the GNU Affero General Public License as published by  */
-/* the Free Software Foundation, either version 3 of the License, or            */
-/* (at your option) any later version.                                          */
-/*                                                                              */
-/* This program is distributed in the hope that it will be useful,              */
-/* but WITHOUT ANY WARRANTY; without even the implied warranty of               */
-/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the                 */
-/* GNU Affero General Public License for more details.                          */
-/*                                                                              */
-/* You should have received a copy of the GNU Affero General Public License     */
-/* along with this program. If not, see .        */
-
-/* Chain Builder Component */
-
-.chain-builder {
-    margin-bottom: 15px;
-    width: 100%;
-}
-
-.chain-builder .form-label {
-    margin-bottom: 0.5rem;
-}
-
-.chain-sequence {
-    display: flex;
-    align-items: center;
-    gap: 0.375rem;
-    flex-wrap: wrap;
-    min-height: 2.25rem;
-    padding: 0.375rem 0.5rem;
-    border: 1px solid var(--lineDivider);
-    border-radius: var(--cornerSmall);
-    background: var(--lineContour);
-    margin-bottom: 0.5rem;
-}
-
-.chain-sequence:empty::after {
-    content: 'No tasks chained';
-    color: var(--textDisabled);
-    font-style: italic;
-    font-size: 0.8125rem;
-}
-
-.chain-item {
-    display: inline-flex;
-    align-items: center;
-    gap: 0.25rem;
-}
-
-.chain-chip {
-    display: inline-flex;
-    align-items: center;
-    gap: 0.25rem;
-    padding: 0.1875rem 0.5rem;
-    background: var(--primarySelected);
-    border: 1px solid var(--primaryMain);
-    border-radius: var(--cornerFull);
-    font-size: 0.8125rem;
-    color: var(--primaryMain);
-    white-space: nowrap;
-    line-height: 1.4;
-}
-
-.chain-chip-remove {
-    display: inline-flex;
-    align-items: center;
-    justify-content: center;
-    width: 1.125rem;
-    height: 1.125rem;
-    padding: 0;
-    border: none;
-    background: none;
-    cursor: pointer;
-    border-radius: var(--cornerFull);
-    color: var(--primaryMain);
-    transition: background-color 0.15s;
-}
-
-.chain-chip-remove:hover {
-    background: var(--primaryFocus);
-}
-
-.chain-chip-remove .material-symbols-outlined {
-    font-size: 0.875rem;
-}
-
-.chain-arrow {
-    color: var(--textSecondary);
-    font-size: 1rem;
-}
-
-.chain-item:last-child .chain-arrow {
-    display: none;
-}
-
-.chain-add-controls {
-    display: flex;
-    align-items: center;
-    gap: 0.5rem;
-}
-
-.chain-task-select {
-    min-width: 200px;
-    max-width: 300px;
-}
-
-.chain-options {
-    margin-top: 0.5rem;
-    font-size: 0.8125rem;
-}
-
-/* Compact variant for periodic task table cells */
-
-.chain-builder--compact {
-    margin-bottom: 0;
-}
-
-.chain-builder--compact .form-label {
-    display: none;
-}
-
-.chain-builder--compact .chain-sequence {
-    min-height: 1.75rem;
-    padding: 0.25rem 0.375rem;
-    margin-bottom: 0.25rem;
-}
-
-.chain-builder--compact .chain-chip {
-    padding: 0.125rem 0.375rem;
-    font-size: 0.75rem;
-}
-
-.chain-builder--compact .chain-chip-remove .material-symbols-outlined {
-    font-size: 0.75rem;
-}
-
-.chain-builder--compact .chain-arrow {
-    font-size: 0.875rem;
-}
-
-.chain-builder--compact .chain-task-select {
-    min-width: 140px;
-    max-width: 200px;
-    font-size: 0.8125rem;
-    padding: 0.25rem;
-}
-
-/* Chain display (read-only, for tables) */
-
-.chain-display {
-    display: inline-flex;
-    align-items: center;
-    gap: 0.25rem;
-    flex-wrap: wrap;
-}
-
-.chain-display--compact {
-    font-size: 0.8125rem;
-}
-
-.chain-chip--small {
-    display: inline-flex;
-    padding: 0.125rem 0.375rem;
-    background: var(--primarySelected);
-    border-radius: var(--cornerFull);
-    font-size: 0.75rem;
-    color: var(--primaryMain);
-    white-space: nowrap;
-}
-
-.chain-arrow-small {
-    color: var(--textSecondary);
-    font-size: 0.75rem;
-}
-
-.chain-depth-indicator {
-    display: inline-flex;
-    align-items: center;
-    gap: 0.125rem;
-    color: var(--textSecondary);
-    font-size: 0.75rem;
-}
-
-.chain-depth-indicator .material-symbols-outlined {
-    font-size: 0.875rem;
-}
diff --git a/static/css/homepage.css b/static/css/homepage.css
deleted file mode 100644
index 6e9c415822..0000000000
--- a/static/css/homepage.css
+++ /dev/null
@@ -1,108 +0,0 @@
-/* Copyright (C) 2026 Percona LLC                                               */
-/*                                                                              */
-/* This program is free software: you can redistribute it and/or modify         */
-/* it under the terms of the GNU Affero General Public License as published by  */
-/* the Free Software Foundation, either version 3 of the License, or            */
-/* (at your option) any later version.                                          */
-/*                                                                              */
-/* This program is distributed in the hope that it will be useful,              */
-/* but WITHOUT ANY WARRANTY; without even the implied warranty of               */
-/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the                 */
-/* GNU Affero General Public License for more details.                          */
-/*                                                                              */
-/* You should have received a copy of the GNU Affero General Public License     */
-/* along with this program. If not, see .        */
-
-.tasks-table-container {
-    border: 1px solid var(--lineDivider);
-    border-radius: 10px;
-}
-
-.tasks-table {
-    border: none;
-    margin-bottom: 0;
-}
-
-.tasks-table td,
-.tasks-table th {
-    padding: 0.8em 1em;
-}
-
-.tasks-table th {
-    font-weight: 600;
-}
-
-.no-tasks {
-    text-align: center;
-}
-
-.previous-log-row {
-    border-bottom: none;
-}
-
-.previous-log-row td {
-    padding-bottom: .4em !important;
-}
-
-.log-row td {
-    padding: 0 0 .2em .8em !important
-}
-
-.scheduled-tasks-table-container {
-    border: 1px solid var(--lineDivider);
-    border-radius: 10px;
-}
-
-#scheduled-tasks-table {
-    border: none;
-    margin-bottom: 0;
-}
-
-#scheduled-tasks-table td,
-#scheduled-tasks-table th {
-    padding: 0.8em 1em;
-}
-
-#scheduled-tasks-table th {
-    font-weight: 600;
-}
-
-#inventories {
-    width: 40%;
-    float: left;
-}
-
-.inventories-container {
-    border-radius: 10px;
-    border: 1px solid var(--lineDivider);
-    width: 90%;
-}
-
-.inventories-table {
-    margin: 0;
-}
-
-.inventories-table td,
-.inventories-table th {
-    padding: 0.8em 1em;
-}
-
-#targets {
-    width: 60%;
-    float: left;
-}
-
-.targets-container {
-    border-radius: 10px;
-    border: 1px solid var(--lineDivider);
-    width: 90%;
-}
-
-.targets-table {
-    margin: 0;
-}
-
-.targets-table td,
-.targets-table th {
-    padding: 0.8em 1em;
-}
diff --git a/static/css/login.css b/static/css/login.css
deleted file mode 100644
index ef76051b6d..0000000000
--- a/static/css/login.css
+++ /dev/null
@@ -1,68 +0,0 @@
-/* Copyright (C) 2026 Percona LLC                                               */
-/*                                                                              */
-/* This program is free software: you can redistribute it and/or modify         */
-/* it under the terms of the GNU Affero General Public License as published by  */
-/* the Free Software Foundation, either version 3 of the License, or            */
-/* (at your option) any later version.                                          */
-/*                                                                              */
-/* This program is distributed in the hope that it will be useful,              */
-/* but WITHOUT ANY WARRANTY; without even the implied warranty of               */
-/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the                 */
-/* GNU Affero General Public License for more details.                          */
-/*                                                                              */
-/* You should have received a copy of the GNU Affero General Public License     */
-/* along with this program. If not, see .        */
-
-body {
-    grid-template-areas: "head head" "main main" "foot foot";
-}
-
-#login-form {
-    max-width: 400px;
-    margin: 1em auto;
-}
-
-#login-form fieldset {
-    border: 0.2em solid var(--lineDivider);
-    border-radius: 1em;
-    padding: 1em;
-}
-
-#login-form legend {
-    font-weight: 600;
-    font-size: 1.2em;
-}
-
-#login-form label {
-    display: inline-block;
-    width: 100px;
-    text-align: left;
-    margin-bottom: 0.5em;
-}
-
-#login-form input[type="text"],
-#login-form input[type="password"] {
-    width: 200px;
-    padding: 0.5em;
-    margin-bottom: 1em;
-    border: 0.2em solid var(--lineDivider);
-    border-radius: 0.4em;
-    font-size: 1em;
-}
-
-#login-form button {
-    background-color: var(--primaryMain);
-    color: var(--primaryContrast);
-    border: none;
-    border-radius: 2em;
-    font-weight: bolder;
-    font-size: 1.1em;
-    padding: 0.5em 1em;
-    margin-top: 1em;
-    cursor: pointer;
-}
-
-#login-form button:not([disabled]):hover {
-    background-color: var(--primaryLight);
-    color: var(--primaryContrast);
-}
diff --git a/static/css/logs.css b/static/css/logs.css
deleted file mode 100644
index 22c6bf07a5..0000000000
--- a/static/css/logs.css
+++ /dev/null
@@ -1,187 +0,0 @@
-/* Copyright (C) 2026 Percona LLC                                               */
-/*                                                                              */
-/* This program is free software: you can redistribute it and/or modify         */
-/* it under the terms of the GNU Affero General Public License as published by  */
-/* the Free Software Foundation, either version 3 of the License, or            */
-/* (at your option) any later version.                                          */
-/*                                                                              */
-/* This program is distributed in the hope that it will be useful,              */
-/* but WITHOUT ANY WARRANTY; without even the implied warranty of               */
-/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the                 */
-/* GNU Affero General Public License for more details.                          */
-/*                                                                              */
-/* You should have received a copy of the GNU Affero General Public License     */
-/* along with this program. If not, see .        */
-
-/*
-.log-console {
-    border: 1px solid #333;
-    font-family: monospace;
-    background-color: #1e1e1e;
-    color: #d4d4d4;
-}
-
-.logs-footer {
-    display: flex;
-    align-items: center;
-    background-color: #f5f5f5;
-}
-
-.logs-footer {
-    border-top: 1px solid #444;
-    padding: 0.5em;
-}
-
-.log-tabs {
-    display: flex;
-}
-
-.log-tabs a {
-    text-decoration: none;
-    font-weight: bold;
-    padding: 0.5em 1em;
-    border: 1px solid transparent;
-    border-bottom: none;
-    background-color: #fff;
-    margin-right: 0.5em;
-}
-
-.log-tabs a.selected {
-    color: #fff;
-    background-color: #93C7FF;
-    border: 1px solid #444;
-    border-bottom: 1px solid #1e1e1e;
-}
-
-.toggle-row {
-    display: flex;
-    align-items: center;
-    margin-left: auto;
-}
-
-.decrypt-toggle {
-    display: flex;
-    align-items: center;
-    margin-left: auto;
-}
-
-.word-wrap-toggle {
-    display: flex;
-    align-items: center;
-    margin-left: 1em;
-}
-
-.toggle-label {
-    margin-left: 0.5em;
-    color: #2C323E;
-    font-weight: bold;
-    font-size: 1em;
-    cursor: default;
-}
-
-.switch {
-    position: relative;
-    display: inline-block;
-    width: 50px;
-    height: 24px;
-}
-
-.switch input {
-    display: none;
-}
-
-.slider {
-    position: absolute;
-    cursor: pointer;
-    top: 0;
-    left: 0;
-    right: 0;
-    bottom: 0;
-    background-color: #ccc;
-    transition: 0.1s;
-    border-radius: 24px;
-}
-
-.slider:before {
-    position: absolute;
-    content: "";
-    height: 18px;
-    width: 18px;
-    left: 3px;
-    bottom: 3px;
-    background-color: white;
-    transition: 0.2s;
-    border-radius: 50%;
-}
-
-input:checked+.slider {
-    background-color: #1060FF;
-}
-
-input:focus+.slider {
-    box-shadow: 0 0 1px #1060FF;
-}
-
-input:checked+.slider:before {
-    transform: translateX(26px);
-}
-
-.logs-content {
-    max-height: 400px;
-    overflow: auto;
-}
-
-.log-output {
-    display: none;
-    padding: 1em;
-    white-space: pre;
-    font-size: 1em;
-    line-height: 1.4em;
-}
-
-.log-output.word-wrap {
-    white-space: pre-wrap;
-}
-
-.logs-button {
-    width: 2em;
-}
-
-.log-step-tab:hover,
-.log-type-tab:hover {
-    border: 1px solid #444;
-    border-bottom: 1px solid #1e1e1e;
-}
-
-.log-console.completed {
-    border: 2px solid #8ACE00;
-}
-
-.log-console.failed {
-    border: 2px solid #CE2900;
-}
-
-.tab-notification {
-    position: relative;
-}
-
-.tab-notification::after {
-    content: '';
-    display: block;
-    position: absolute;
-    top: 5px;
-    right: -5px;
-    height: 8px;
-    width: 8px;
-    background-color: red;
-    border-radius: 50%;
-}
-
-.unselectable {
-    -webkit-touch-callout: none;
-    -webkit-user-select: none;
-    -khtml-user-select: none;
-    -moz-user-select: none;
-    -ms-user-select: none;
-    user-select: none;
-} */
diff --git a/static/css/messages.css b/static/css/messages.css
deleted file mode 100644
index 9f03d8ae3c..0000000000
--- a/static/css/messages.css
+++ /dev/null
@@ -1,60 +0,0 @@
-/* Copyright (C) 2026 Percona LLC                                               */
-/*                                                                              */
-/* This program is free software: you can redistribute it and/or modify         */
-/* it under the terms of the GNU Affero General Public License as published by  */
-/* the Free Software Foundation, either version 3 of the License, or            */
-/* (at your option) any later version.                                          */
-/*                                                                              */
-/* This program is distributed in the hope that it will be useful,              */
-/* but WITHOUT ANY WARRANTY; without even the implied warranty of               */
-/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the                 */
-/* GNU Affero General Public License for more details.                          */
-/*                                                                              */
-/* You should have received a copy of the GNU Affero General Public License     */
-/* along with this program. If not, see .        */
-
-#messages {
-    position: absolute;
-    width: 65%;
-    left: 50%;
-    transform: translate(-50%);
-    z-index: 9999;
-}
-
-.message {
-    position: relative;
-    margin-bottom: 0.5em;
-    padding: 1em 2em 1em 1em;
-    border-radius: 0.3em;
-    font-weight: 500;
-}
-
-.close-button {
-    position: absolute;
-    top: 0.4em;
-    right: 1em;
-    background: none !important;
-    color: inherit !important;
-    border: 0 !important;
-    margin-top: 0;
-}
-
-.message[data-level="info"] {
-    background-color: var(--infoSurface);
-    color: var(--infoContrast);
-}
-
-.message[data-level="success"] {
-    background-color: var(--successSurface);
-    color: var(--successContrast);
-}
-
-.message[data-level="warning"] {
-    background-color: var(--warningSurface);
-    color: var(--warningContrast);
-}
-
-.message[data-level="error"] {
-    background-color: var(--errorSurface);
-    color: var(--errorContrast);
-}
diff --git a/static/css/mysql_backups-docs.css b/static/css/mysql_backups-docs.css
deleted file mode 100644
index 38bd1947fb..0000000000
--- a/static/css/mysql_backups-docs.css
+++ /dev/null
@@ -1,120 +0,0 @@
-/**
- * Copyright (C) 2026 Percona LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- */
-
-article.backup-docs {
-    max-width: 960px;
-    margin: 0 auto 4rem;
-    display: grid;
-    gap: 2.5rem;
-}
-
-.backup-docs__header {
-    display: flex;
-    justify-content: space-between;
-    align-items: flex-start;
-    gap: 1rem;
-    padding: 2.5rem 2rem 2rem;
-    border-radius: 12px;
-    background: var(--surfaceElevation1);
-    box-shadow: var(--elevation2);
-    border: 1px solid var(--lineDivider);
-}
-
-.backup-docs__header-actions {
-    display: flex;
-    flex-wrap: wrap;
-    align-items: center;
-    gap: 0.75rem;
-}
-
-.backup-docs__header-actions a {
-    display: inline-flex;
-    align-items: center;
-    gap: 0.35rem;
-    font-weight: 600;
-    color: var(--primaryMain);
-    text-decoration: none;
-}
-
-.backup-docs__header-actions a:hover,
-.backup-docs__header-actions a:focus-visible {
-    text-decoration: underline;
-}
-
-.backup-docs__section {
-    background: var(--surfaceElevation1);
-    border-radius: 12px;
-    border: 1px solid var(--lineDivider);
-    box-shadow: var(--elevation1);
-    padding: 2rem;
-    display: grid;
-    gap: 1.5rem;
-}
-
-.backup-docs__section h2 {
-    margin: 0;
-}
-
-.backup-docs__section ul,
-.backup-docs__section ol {
-    margin: 0;
-    padding-left: 1.5rem;
-}
-
-.backup-docs__section li+li {
-    margin-top: 0.5rem;
-}
-
-.backup-docs__section dl {
-    margin: 0;
-    display: grid;
-    gap: 1rem;
-}
-
-.backup-docs__section dt {
-    font-weight: 600;
-    color: var(--textPrimary);
-}
-
-.backup-docs__section dd {
-    margin: 0.25rem 0 0 0;
-    color: var(--textSecondary);
-}
-
-.backup-docs__section .field-grid {
-    display: grid;
-    gap: 1rem;
-}
-
-.backup-docs__section .field-grid>div {
-    display: grid;
-    gap: 0.35rem;
-}
-
-@media (min-width: 720px) {
-    .backup-docs__section .field-grid {
-        grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
-    }
-}
-
-@media (max-width: 576px) {
-
-    .backup-docs__header,
-    .backup-docs__section {
-        padding: 1.5rem;
-    }
-}
diff --git a/static/css/report.css b/static/css/report.css
deleted file mode 100644
index 17bf85142f..0000000000
--- a/static/css/report.css
+++ /dev/null
@@ -1,293 +0,0 @@
-/* Copyright (C) 2026 Percona LLC
-
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as published by
-the Free Software Foundation, either version 3 of the License, or
-(at your option) any later version.
-
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-GNU Affero General Public License for more details.
-
-You should have received a copy of the GNU Affero General Public License
-along with this program. If not, see . */
-
-/* Report plugin styles */
-
-.report-layout {
-    max-width: 80rem;
-    margin: 0 auto;
-}
-
-.report-header {
-    margin-bottom: 2rem;
-}
-
-.report-header h1 {
-    margin-bottom: 0.25rem;
-}
-
-.report-header .subtitle {
-    color: var(--textSecondary);
-    margin: 0.15rem 0;
-    font-size: 0.95rem;
-}
-
-.report-intro {
-    margin-bottom: 2rem;
-    line-height: 1.6;
-}
-
-.report-form fieldset {
-    border: 0.1rem solid var(--lineDivider);
-    border-radius: 1rem;
-    padding: 1.5rem;
-    margin-bottom: 1.5rem;
-}
-
-.report-form legend {
-    font-weight: 600;
-    padding: 0 0.5rem;
-}
-
-.report-form .form-row {
-    display: flex;
-    align-items: center;
-    gap: 1rem;
-    margin-bottom: 0.75rem;
-}
-
-.report-form .form-row label {
-    min-width: 4rem;
-    font-weight: 500;
-}
-
-.report-form .form-row select,
-.report-form .form-row input {
-    flex: 1;
-    max-width: 20rem;
-}
-
-.report-layout .form-actions {
-    display: flex;
-    flex-wrap: wrap;
-    gap: 1rem;
-    align-items: center;
-}
-
-/* Anchor-as-button: mirrors button styles from base.css */
-
-a.btn,
-a.btn:link,
-a.btn:visited {
-    font-family: 'Poppins', sans-serif;
-    font-weight: 600;
-    line-height: 1.0625;
-    display: inline-flex;
-    justify-content: center;
-    align-items: center;
-    gap: 0.25rem;
-    border-radius: var(--cornerFull);
-    text-decoration: none;
-    cursor: pointer;
-}
-
-.btn.large {
-    font-size: 0.9375em;
-    padding: 1rem 1.5rem;
-}
-
-a.btn.contained,
-a.btn.contained:link,
-a.btn.contained:visited {
-    color: var(--primaryContrast);
-    background: var(--primaryMain);
-    border: none;
-    box-shadow: var(--elevation2);
-    transition: background .2s, box-shadow .2s;
-}
-
-a.btn.contained:hover {
-    background: var(--primaryDark);
-    box-shadow: var(--elevation4);
-}
-
-a.btn.outlined,
-a.btn.outlined:link,
-a.btn.outlined:visited {
-    color: var(--primaryMain);
-    border: 0.125rem solid var(--primaryMain);
-    background: none;
-    transition: border .2s, color .2s, background .2s;
-}
-
-a.btn.outlined:hover {
-    color: var(--primaryDark);
-    background: var(--primaryHover);
-    border-color: var(--primaryDark);
-}
-
-/* Report result */
-
-.report-toc {
-    border: 0.1rem solid var(--lineDivider);
-    border-radius: 1rem;
-    padding: 1.5rem;
-    margin-bottom: 2rem;
-}
-
-.report-toc h2 {
-    margin-top: 0;
-    font-size: 1.1rem;
-}
-
-.report-toc ul {
-    list-style: none;
-    padding: 0;
-    margin: 0;
-    display: flex;
-    flex-direction: column;
-    gap: 0.25rem;
-}
-
-.report-toc a {
-    display: block;
-    padding: 0.5rem 0.75rem;
-    border-radius: 0.5rem;
-    text-decoration: none;
-    font-weight: 500;
-    transition: background-color 0.2s;
-}
-
-.report-toc a:hover {
-    background-color: var(--actionHover);
-}
-
-.report-section {
-    border: 0.1rem solid var(--lineDivider);
-    border-radius: 1rem;
-    padding: 1.5rem;
-    margin-bottom: 2rem;
-}
-
-.report-section h2 {
-    margin-top: 0;
-    font-size: 1.4rem;
-    border-bottom: 0.1rem dotted var(--lineDivider);
-    padding-bottom: 0.5rem;
-}
-
-.report-section h3 {
-    font-size: 1.1rem;
-    border-bottom: 0.1rem dotted var(--lineDivider);
-    padding-bottom: 0.3rem;
-}
-
-.report-grid {
-    display: grid;
-    grid-template-columns: auto 1fr;
-    gap: 0.25rem 1.5rem;
-    margin-bottom: 1.5rem;
-}
-
-.report-grid dt {
-    font-weight: 500;
-}
-
-.report-section table {
-    width: 100%;
-    margin-bottom: 1.5rem;
-}
-
-.report-section caption {
-    text-align: left;
-    font-weight: 600;
-    font-size: 1rem;
-    padding-bottom: 0.5rem;
-}
-
-.report-section th,
-.report-section td {
-    text-align: left;
-    padding: 0.5rem;
-    vertical-align: top;
-}
-
-.report-section thead th {
-    background-color: var(--surfaceElevation2);
-    border-radius: 0.5rem;
-}
-
-.report-section th.status,
-.report-section td.status {
-    max-width: 5rem;
-    text-align: center;
-}
-
-.report-footer {
-    text-align: center;
-    padding: 1.5rem 0;
-    border-top: 0.1rem solid var(--lineDivider);
-    margin-top: 2rem;
-}
-
-.report-footer p {
-    color: var(--textSecondary);
-    font-size: 0.9rem;
-}
-
-/* Radio buttons - mirrors .checkbox / .checkmark pattern from base.css */
-.radio-group {
-    display: flex;
-    flex-wrap: wrap;
-    gap: 0 1.5rem;
-}
-
-.radio {
-    display: flex;
-    align-items: center;
-    user-select: none;
-    cursor: pointer;
-}
-
-input[type="radio"] {
-    opacity: 0;
-    height: 0;
-    width: 0;
-    cursor: pointer;
-}
-
-.radiomark {
-    display: flex;
-    justify-content: center;
-    align-items: center;
-    width: 2.5rem;
-    height: 2.5rem;
-    border-radius: var(--cornerFull);
-    transition: background 0.2s;
-}
-
-.radio:hover .radiomark {
-    background: var(--primaryHover);
-}
-
-.radiomark .checked {
-    color: var(--primaryMain);
-}
-
-input[type="radio"]~.radiomark .checked,
-input[type="radio"]:checked~.radiomark .unchecked {
-    display: none;
-}
-
-input[type="radio"]~.radiomark .unchecked,
-input[type="radio"]:checked~.radiomark .checked {
-    display: block;
-}
-
-input[type="radio"]:focus-visible~.radiomark {
-    background: var(--primaryFocus);
-    outline: 0.1875rem solid var(--lineFocus);
-    outline-offset: -0.1875rem;
-}
diff --git a/static/css/saved.css b/static/css/saved.css
deleted file mode 100644
index 687474af18..0000000000
--- a/static/css/saved.css
+++ /dev/null
@@ -1,38 +0,0 @@
-/* Copyright (C) 2026 Percona LLC                                               */
-/*                                                                              */
-/* This program is free software: you can redistribute it and/or modify         */
-/* it under the terms of the GNU Affero General Public License as published by  */
-/* the Free Software Foundation, either version 3 of the License, or            */
-/* (at your option) any later version.                                          */
-/*                                                                              */
-/* This program is distributed in the hope that it will be useful,              */
-/* but WITHOUT ANY WARRANTY; without even the implied warranty of               */
-/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the                 */
-/* GNU Affero General Public License for more details.                          */
-/*                                                                              */
-/* You should have received a copy of the GNU Affero General Public License     */
-/* along with this program. If not, see .        */
-
-/* .schedule-button.toggled-on {
-    background-color: #93C7FF;
-    color: #2C323E;
-}
-
-.eta-input {
-    width: 0;
-    padding: 0;
-    margin-right: 0;
-    opacity: 0;
-    border: 0.2em solid #F0F1F4;
-    border-radius: 2em;
-    overflow: hidden;
-    font-size: 1em;
-}
-
-.send-form.expanded .eta-input {
-    width: 210px;
-    padding: 0.5em;
-    margin-right: 0.5em;
-    opacity: 1;
-    transition: width 0.5s ease, padding 0.5s ease, margin-right 0.5s ease, opacity 0.5s ease;
-} */
diff --git a/static/css/scheduled.css b/static/css/scheduled.css
deleted file mode 100644
index be392af533..0000000000
--- a/static/css/scheduled.css
+++ /dev/null
@@ -1,246 +0,0 @@
-/* Copyright (C) 2026 Percona LLC                                               */
-/*                                                                              */
-/* This program is free software: you can redistribute it and/or modify         */
-/* it under the terms of the GNU Affero General Public License as published by  */
-/* the Free Software Foundation, either version 3 of the License, or            */
-/* (at your option) any later version.                                          */
-/*                                                                              */
-/* This program is distributed in the hope that it will be useful,              */
-/* but WITHOUT ANY WARRANTY; without even the implied warranty of               */
-/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the                 */
-/* GNU Affero General Public License for more details.                          */
-/*                                                                              */
-/* You should have received a copy of the GNU Affero General Public License     */
-/* along with this program. If not, see .        */
-
-#period-header {
-    width: 250px;
-}
-
-#scheduled-tasks-table select {
-    margin-top: 0;
-}
-
-#start-time-header {
-    width: 225px;
-}
-
-#scheduled-tasks-table.create-mode #start-time-header,
-#scheduled-tasks-table.create-mode #expires-header {
-    width: 225px;
-}
-
-#last-run-at-header {
-    width: 200px;
-}
-
-#scheduled-tasks-table.create-mode #last-run-at-header {
-    width: 200px;
-}
-
-#scheduled-tasks-table.create-mode .hide-on-create-mode {
-    display: none;
-}
-
-#scheduled-tasks-table:not(.create-mode) .show-on-create-mode {
-    display: none;
-}
-
-.period-inputs select {
-    height: 28px;
-}
-
-.period-inputs input {
-    height: 28px;
-    margin-right: 10px;
-}
-
-.actions-cell {
-    align-items: center;
-}
-
-.enable-toggle {
-    display: flex;
-    align-items: center;
-}
-
-.enable-form {
-    padding-left: 0.5em;
-    padding-top: 0.5em;
-}
-
-.delete-form {
-    padding-top: 0.5em;
-}
-
-.toggle-label {
-    margin-left: 0.5em;
-    color: var(--textPrimary);
-    font-weight: bold;
-    font-size: 1em;
-    cursor: default;
-}
-
-.switch {
-    position: relative;
-    display: inline-block;
-    width: 50px;
-    height: 24px;
-}
-
-.switch input {
-    display: none;
-}
-
-.slider {
-    position: absolute;
-    cursor: pointer;
-    top: 0;
-    left: 0;
-    right: 0;
-    bottom: 0;
-    background-color: var(--actionDisabled);
-    transition: 0.1s;
-    border-radius: 24px;
-}
-
-.slider.round {
-    border-radius: 24px;
-}
-
-.slider:before {
-    position: absolute;
-    content: "";
-    height: 18px;
-    width: 18px;
-    left: 3px;
-    bottom: 3px;
-    background-color: var(--surfaceElevation1);
-    transition: 0.2s;
-    border-radius: 50%;
-}
-
-input:checked+.slider {
-    background-color: var(--primaryMain);
-}
-
-input:focus+.slider {
-    box-shadow: 0 0 1px var(--primaryMain);
-}
-
-input:checked+.slider:before {
-    transform: translateX(26px);
-}
-
-.period-cell {
-    position: relative;
-}
-
-.new-periodic-task-form {
-    margin: 0;
-    padding: 0;
-}
-
-.new-periodic-task-row input,
-.new-periodic-task-row select,
-.edit-periodic-task-row input,
-.edit-periodic-task-row select {
-    max-width: 100%;
-    box-sizing: border-box;
-    margin-top: 0;
-}
-
-.new-periodic-task-row .actions-cell,
-.edit-periodic-task-row .actions-cell,
-#add-periodic-task-row .actions-cell {
-    display: flex;
-    align-items: center;
-    padding: 1em;
-    padding-top: .5em;
-}
-
-.new-periodic-task-row .save-button,
-.new-periodic-task-row .discard-button,
-.edit-periodic-task-row .save-edit-button,
-.edit-periodic-task-row .cancel-edit-button {
-    margin-right: 0.5em;
-}
-
-.edit-button {
-    margin-right: 0.5em;
-    padding-top: 0.5em;
-}
-
-.edit-periodic-task-row {
-    background-color: var(--primarySelected);
-}
-
-.edit-periodic-task-row td {
-    padding: 1em 0.5em;
-}
-
-.start-time-input {
-    padding: 0.5em;
-    margin-right: 0.5em;
-    border: 0.2em solid var(--lineDivider);
-    border-radius: 2em;
-    font-size: 1em;
-}
-
-.interval-inputs {
-    display: flex;
-}
-
-.cron-inputs {
-    display: flex;
-    flex-direction: column;
-}
-
-.interval-inputs input[type="number"],
-.cron-inputs input[type="text"] {
-    width: 100px;
-    margin-bottom: 0.5em;
-}
-
-.cron-description {
-    font-style: italic;
-    font-size: 10px;
-    color: var(--textSecondary);
-}
-
-.invalid {
-    border-color: red;
-}
-
-.change-to-cron,
-.change-to-interval {
-    font-size: 0.9em;
-    color: var(--primaryMain);
-    cursor: pointer;
-    text-decoration: underline;
-    margin-top: 0.5em;
-    display: inline-block;
-}
-
-.highlighted-row {
-    animation: highlight-animation 3s ease-in-out;
-}
-
-@keyframes highlight-animation {
-    0% {
-        background-color: var(--primarySelected);
-    }
-
-    100% {
-        background-color: transparent;
-    }
-}
-
-
-.hidden {
-    display: none;
-}
-
-.select2-container {
-    font-size: 10px;
-}
diff --git a/static/css/simple-datatables-style.css b/static/css/simple-datatables-style.css
deleted file mode 100644
index 2791ebaa9a..0000000000
--- a/static/css/simple-datatables-style.css
+++ /dev/null
@@ -1,502 +0,0 @@
-/* Copyright (C) 2026 Percona LLC                                               */
-/*                                                                              */
-/* This program is free software: you can redistribute it and/or modify         */
-/* it under the terms of the GNU Affero General Public License as published by  */
-/* the Free Software Foundation, either version 3 of the License, or            */
-/* (at your option) any later version.                                          */
-/*                                                                              */
-/* This program is distributed in the hope that it will be useful,              */
-/* but WITHOUT ANY WARRANTY; without even the implied warranty of               */
-/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the                 */
-/* GNU Affero General Public License for more details.                          */
-/*                                                                              */
-/* You should have received a copy of the GNU Affero General Public License     */
-/* along with this program. If not, see .        */
-
-.datatable-wrapper.no-header .datatable-container {
-    border-top: 1px solid var(--lineDivider);
-}
-
-.datatable-wrapper.no-footer .datatable-container {
-    border-bottom: 1px solid var(--lineDivider);
-}
-
-.datatable-top,
-.datatable-bottom {
-    padding: 8px 10px;
-}
-
-.datatable-top>nav:first-child,
-.datatable-top>div:first-child,
-.datatable-bottom>nav:first-child,
-.datatable-bottom>div:first-child {
-    float: left;
-}
-
-.datatable-top>nav:last-child,
-.datatable-top>div:not(first-child),
-.datatable-bottom>nav:last-child,
-.datatable-bottom>div:last-child {
-    float: right;
-}
-
-.datatable-selector {
-    padding: 6px;
-}
-
-.datatable-input {
-    padding: 6px 12px;
-}
-
-.datatable-info {
-    margin: 7px 0;
-}
-
-/* PAGER */
-.datatable-pagination ul {
-    margin: 0;
-    padding-left: 0;
-}
-
-.datatable-pagination li {
-    list-style: none;
-    float: left;
-}
-
-.datatable-pagination li.datatable-hidden {
-    visibility: hidden;
-}
-
-.datatable-pagination a,
-.datatable-pagination button {
-    border: 1px solid transparent;
-    float: left;
-    margin-left: 2px;
-    padding: 6px 12px;
-    position: relative;
-    text-decoration: none;
-    color: var(--textPrimary);
-    cursor: pointer;
-}
-
-.datatable-pagination a:hover,
-.datatable-pagination button:hover {
-    background-color: var(--actionHover);
-}
-
-.datatable-pagination .datatable-active a,
-.datatable-pagination .datatable-active a:focus,
-.datatable-pagination .datatable-active a:hover,
-.datatable-pagination .datatable-active button,
-.datatable-pagination .datatable-active button:focus,
-.datatable-pagination .datatable-active button:hover {
-    background-color: var(--actionHover);
-    cursor: default;
-}
-
-.datatable-pagination .datatable-ellipsis a,
-.datatable-pagination .datatable-disabled a,
-.datatable-pagination .datatable-disabled a:focus,
-.datatable-pagination .datatable-disabled a:hover,
-.datatable-pagination .datatable-ellipsis button,
-.datatable-pagination .datatable-disabled button,
-.datatable-pagination .datatable-disabled button:focus,
-.datatable-pagination .datatable-disabled button:hover {
-    pointer-events: none;
-    cursor: default;
-}
-
-.datatable-pagination .datatable-disabled a,
-.datatable-pagination .datatable-disabled a:focus,
-.datatable-pagination .datatable-disabled a:hover,
-.datatable-pagination .datatable-disabled button,
-.datatable-pagination .datatable-disabled button:focus,
-.datatable-pagination .datatable-disabled button:hover {
-    cursor: not-allowed;
-    opacity: 0.4;
-}
-
-.datatable-pagination .datatable-pagination a,
-.datatable-pagination .datatable-pagination button {
-    font-weight: bold;
-}
-
-/* TABLE */
-.datatable-table {
-    max-width: 100%;
-    width: 100%;
-    border-spacing: 0;
-    border-collapse: separate;
-}
-
-.datatable-table>tbody>tr>td,
-.datatable-table>tbody>tr>th,
-.datatable-table>tfoot>tr>td,
-.datatable-table>tfoot>tr>th,
-.datatable-table>thead>tr>td,
-.datatable-table>thead>tr>th {
-    vertical-align: top;
-    padding: 8px 10px;
-}
-
-.datatable-table>thead>tr>th {
-    vertical-align: bottom;
-    text-align: left;
-    border-bottom: 1px solid var(--lineDivider);
-}
-
-.datatable-table>tfoot>tr>th {
-    vertical-align: bottom;
-    text-align: left;
-    border-top: 1px solid var(--lineDivider);
-}
-
-.datatable-table th {
-    vertical-align: bottom;
-    text-align: left;
-}
-
-.datatable-table th a {
-    text-decoration: none;
-    color: inherit;
-}
-
-.datatable-table th button,
-.datatable-pagination-list button {
-    color: inherit;
-    border: 0;
-    background-color: inherit;
-    cursor: pointer;
-    text-align: inherit;
-    font-family: inherit;
-    font-weight: inherit;
-    font-size: inherit;
-}
-
-.datatable-sorter,
-.datatable-filter {
-    display: inline-block;
-    height: 100%;
-    position: relative;
-    width: 100%;
-}
-
-.datatable-sorter::before,
-.datatable-sorter::after {
-    content: "";
-    height: 0;
-    width: 0;
-    position: absolute;
-    right: 4px;
-    border-left: 4px solid transparent;
-    border-right: 4px solid transparent;
-    opacity: 0.2;
-}
-
-
-.datatable-sorter::before {
-    border-top: 4px solid var(--textPrimary);
-    bottom: 0px;
-}
-
-.datatable-sorter::after {
-    border-bottom: 4px solid var(--textPrimary);
-    border-top: 4px solid transparent;
-    top: 0px;
-}
-
-.datatable-ascending .datatable-sorter::after,
-.datatable-descending .datatable-sorter::before,
-.datatable-ascending .datatable-filter::after,
-.datatable-descending .datatable-filter::before {
-    opacity: 0.6;
-}
-
-.datatable-filter::before {
-    content: "";
-    position: absolute;
-    right: 4px;
-    opacity: 0.2;
-    width: 0;
-    height: 0;
-    border-left: 7px solid transparent;
-    border-right: 7px solid transparent;
-    border-radius: 50%;
-    border-top: 10px solid var(--textPrimary);
-    top: 25%;
-}
-
-.datatable-filter-active .datatable-filter::before {
-    opacity: 0.6;
-}
-
-.datatable-empty {
-    text-align: center;
-}
-
-.datatable-top::after,
-.datatable-bottom::after {
-    clear: both;
-    content: " ";
-    display: table;
-}
-
-table.datatable-table:focus tr.datatable-cursor>td:first-child {
-    border-left: 3px var(--lineFocus) solid;
-}
-
-table.datatable-table:focus {
-    outline: solid 1px var(--lineDividerStronger);
-    outline-offset: -1px;
-}
-
-/* Editing.css */
-
-.datatable-editor-modal {
-    position: absolute;
-    left: 0;
-    top: 0;
-    width: 100%;
-    height: 100%;
-    background-color: rgba(0, 0, 0, 0.6);
-    -webkit-animation: 250ms ease 0ms fadeIn;
-    animation: 250ms ease 0ms fadeIn;
-}
-
-.datatable-editor-modal.closed {
-    -webkit-animation: 250ms ease 0ms fadeIn;
-    animation: 250ms ease 0ms fadeIn;
-}
-
-.datatable-editor-modal.closed .datatable-editor-inner {
-    -webkit-animation: 250ms ease 0ms slideIn;
-    animation: 250ms ease 0ms slideIn;
-}
-
-.datatable-editor-inner {
-    width: 30%;
-    margin: 10% auto;
-    background-color: var(--surfaceElevation1);
-    border-radius: 5px;
-    -webkit-animation: 250ms ease 0ms slideIn;
-    animation: 250ms ease 0ms slideIn;
-}
-
-.datatable-editor-header {
-    position: relative;
-    border-bottom: 1px solid var(--lineDivider);
-}
-
-.datatable-editor-header h4 {
-    font-size: 20px;
-    margin: 0;
-}
-
-.datatable-editor-header button {
-    position: absolute;
-    right: 10px;
-    top: 10px;
-    background-color: transparent;
-    border: none;
-    cursor: pointer;
-    font-size: 24px;
-    padding: 5px;
-    line-height: 1;
-    opacity: 0.6;
-}
-
-.datatable-editor-header button:hover {
-    opacity: 1;
-}
-
-.datatable-editor-header {
-    padding: 15px 30px;
-}
-
-.datatable-editor-block {
-    padding: 15px 60px;
-}
-
-.datatable-editor-row {
-    margin: 0 0 15px;
-}
-
-.datatable-editor-row:last-child {
-    margin: 0 0 5px;
-}
-
-.datatable-editor-row:last-child {
-    text-align: right;
-}
-
-.datatable-editor-label {
-    width: 25%;
-    text-align: right;
-    padding: 0 15px;
-}
-
-.datatable-editor-label,
-.datatable-editor-input {
-    display: inline-block;
-}
-
-.datatable-editor-input {
-    padding: 4px 6px;
-    border: 1px solid var(--lineDivider);
-    width: 100%;
-    box-sizing: border-box;
-    margin: -5px 0;
-    font-size: inherit;
-    font-family: inherit;
-    font-weight: inherit;
-    color: inherit;
-}
-
-.datatable-editor-row .datatable-editor-input {
-    margin: 0;
-    width: 75%;
-}
-
-.datatable-editor-save,
-.datatable-editor-cancel {
-    padding: 6px 12px;
-    font-size: inherit;
-    font-family: inherit;
-    font-weight: inherit;
-    cursor: pointer;
-    border-radius: 3px;
-}
-
-.datatable-editor-save {
-    background-color: var(--successMain);
-    border: 1px solid var(--successMain);
-    color: var(--white);
-}
-
-.datatable-editor-save:hover {
-    background-color: var(--successLight);
-    border-color: var(--successLight);
-}
-
-/* ContextMenu */
-.datatable-editor-wrapper {
-    position: absolute;
-}
-
-.datatable-editor-menu {
-    background: var(--surfaceElevation1) none repeat scroll 0 0;
-    border-radius: 3px;
-    margin: 0;
-    min-width: 220px;
-    padding: 5px 0;
-    box-shadow: var(--elevation8);
-}
-
-.datatable-editor-menu li {
-    list-style: none;
-}
-
-.datatable-editor-menu a {
-    box-sizing: border-box;
-    color: inherit;
-    display: block;
-    padding: 5px 15px;
-    text-decoration: none;
-    width: 100%;
-}
-
-.datatable-editor-menu a:hover {
-    background-color: var(--actionHover);
-}
-
-.datatable-editor-separator {
-    border-bottom: 1px solid var(--lineDivider);
-    margin: 5px 0;
-}
-
-@-webkit-keyframes fadeIn {
-    from {
-        opacity: 0;
-    }
-
-    to {
-        opacity: 1;
-    }
-}
-
-@keyframes fadeIn {
-    from {
-        opacity: 0;
-    }
-
-    to {
-        opacity: 1;
-    }
-}
-
-@-webkit-keyframes slideIn {
-    from {
-        opacity: 0;
-        -webkit-transform: translate3d(0, -10%, 0);
-        transform: translate3d(0, -10%, 0);
-    }
-
-    to {
-        opacity: 1;
-        -webkit-transform: translate3d(0, 0%, 0);
-        transform: translate3d(0, 0%, 0);
-    }
-}
-
-@keyframes slideIn {
-    from {
-        opacity: 0;
-        -webkit-transform: translate3d(0, -10%, 0);
-        transform: translate3d(0, -10%, 0);
-    }
-
-    to {
-        opacity: 1;
-        -webkit-transform: translate3d(0, 0%, 0);
-        transform: translate3d(0, 0%, 0);
-    }
-}
-
-.datatable-editor-action .mdi {
-    margin-right: 5px;
-    color: var(--textSecondary);
-}
-
-/* Column Filter */
-
-.datatable-column-filter-button {
-    cursor: pointer;
-}
-
-button.datatable-column-filter-button {
-    padding: 6px 12px;
-}
-
-/* Menu */
-
-.datatable-column-filter-wrapper {
-    position: absolute;
-}
-
-.datatable-column-filter-menu {
-    background: var(--surfaceElevation1) none repeat scroll 0 0;
-    border-radius: 3px;
-    margin: 0;
-    min-width: 220px;
-    padding: 5px 0;
-    box-shadow: var(--elevation8);
-}
-
-.datatable-column-filter-menu>li {
-    list-style: none;
-}
-
-.datatable-column-filter-menu>li,
-.datatable-column-filter-menu>li>label {
-    cursor: pointer;
-}
diff --git a/static/css/snippets-details.css b/static/css/snippets-details.css
deleted file mode 100644
index d7b24aaee0..0000000000
--- a/static/css/snippets-details.css
+++ /dev/null
@@ -1,285 +0,0 @@
-/**
- * Copyright (C) 2026 Percona LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- */
-
-.highlight {
-    width: 90%;
-}
-
-.highlight pre {
-    max-height: 400px;
-}
-
-.highlight-wrapper {
-    width: 90%;
-    max-height: 400px;
-    overflow: auto;
-}
-
-[data-theme="light"] .highlight-wrapper {
-    background: #ffffff;
-    color: #000000;
-}
-
-[data-theme="dark"] .highlight-wrapper {
-    background: #272822;
-    color: #F8F8F2;
-}
-
-.highlight-wrapper .highlight {
-    width: 100%;
-    background: transparent;
-}
-
-.highlight-wrapper .highlight pre {
-    max-height: none;
-}
-
-.highlighttable td {
-    vertical-align: top;
-}
-
-.linenodiv {
-    line-height: 125%;
-}
-
-.highlighttable pre code {
-    font-size: inherit;
-    font-family: inherit;
-}
-
-.highlight-wrapper .highlighttable {
-    table-layout: fixed;
-    width: 100%;
-}
-
-.highlight-wrapper .linenos {
-    width: calc(var(--linenos-width, 3ch) + 20px);
-}
-
-.highlight-wrapper .linenos pre {
-    text-align: right;
-}
-
-.frontmatter-toggle {
-    border: none;
-    margin: 0;
-    padding: 0;
-}
-
-.frontmatter-toggle summary {
-    cursor: pointer;
-    padding: 2px 10px;
-    font-family: monospace;
-    font-size: 0.85em;
-    list-style: none;
-    user-select: none;
-}
-
-[data-theme="light"] .frontmatter-toggle summary {
-    color: #6e7781;
-}
-
-[data-theme="dark"] .frontmatter-toggle summary {
-    color: #90918B;
-}
-
-.frontmatter-toggle summary::-webkit-details-marker {
-    display: none;
-}
-
-td.code {
-    width: calc(70vw + 40px);
-    max-width: calc(70vw + 40px);
-}
-
-.linenos {
-    width: max-content;
-    padding-right: 15px;
-}
-
-.linenos pre {
-    overflow: hidden;
-    color: var(--textSecondary);
-}
-
-.code div {
-    margin-right: 10px;
-}
-
-.code pre {
-    overflow: auto;
-    margin-right: 10px;
-}
-
-#actions form {
-    padding: 0;
-}
-
-#actions {
-    display: inline-flex;
-    gap: 1em;
-}
-
-label {
-    display: flex;
-    align-items: center;
-    width: max-content;
-}
-
-label>span:first-child {
-    width: 200px;
-    display: inline-block;
-}
-
-label:has(> select)>.material-symbols-outlined {
-    position: absolute;
-    top: 0.5rem;
-    right: 0.75rem;
-    z-index: 9999;
-    pointer-events: none;
-}
-
-label:has(> select) {
-    position: relative;
-    display: inline-block;
-}
-
-select {
-    padding-right: 2.625em;
-}
-
-fieldset {
-    display: grid;
-    gap: 0.625em;
-}
-
-fieldset:disabled,
-fieldset:disabled * {
-    color: var(--textDisabled);
-    cursor: not-allowed;
-}
-
-label:has(> input[type="checkbox"]:not(:disabled)) {
-    display: flex;
-    flex-wrap: wrap;
-    align-items: center;
-    user-select: none;
-    cursor: pointer;
-}
-
-label:has(> input[type="checkbox"]:disabled)>.checkmark {
-    opacity: 0.5;
-}
-
-.checkmark {
-    margin-left: -10px;
-    justify-content: left;
-}
-
-#snippetExecuteForm input:disabled {
-    display: initial;
-}
-
-.snippetDisabledWarning {
-    color: var(--errorMain);
-    font-weight: 600;
-}
-
-.sudoAlways {
-    color: var(--errorMain);
-    font-weight: 600;
-}
-
-.sudoOptional {
-    color: var(--warningMain);
-    font-weight: 500;
-}
-
-.download-file-button.loading {
-    opacity: 0.75;
-    cursor: not-allowed;
-}
-
-.download-file-button.loading .material-symbols-outlined {
-    animation: pulse 1s infinite ease-in-out;
-    display: initial;
-}
-
-@keyframes pulse {
-    0% {
-        opacity: .5
-    }
-
-    50% {
-        opacity: 1
-    }
-
-    100% {
-        opacity: .5
-    }
-}
-
-.info-icon {
-    display: inline-flex;
-    align-items: center;
-    cursor: help;
-    margin-left: 0.25em;
-    vertical-align: middle;
-    color: var(--textSecondary, #666);
-    opacity: 0.25;
-    transition: opacity 0.15s ease;
-}
-
-.info-icon:hover,
-.info-icon:focus-visible {
-    opacity: 1;
-}
-
-.info-icon:focus-visible {
-    outline: 2px solid currentColor;
-    outline-offset: 2px;
-}
-
-.info-icon .material-symbols-outlined {
-    font-size: 1.1rem;
-}
-
-.info-tooltip-floating {
-    position: fixed;
-    z-index: 2147483647;
-    min-width: 180px;
-    max-width: 200px;
-    padding: 0.5em 0.75em;
-    border-radius: 6px;
-    background: var(--surfaceElevation1, #fff);
-    color: var(--textPrimary, #333);
-    font-size: 0.875rem;
-    line-height: 1.4;
-    border: 1px solid var(--lineDivider, #ddd);
-    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
-    pointer-events: none;
-    white-space: normal;
-    word-wrap: break-word;
-    opacity: 0;
-    visibility: hidden;
-    transition: opacity 0.15s ease, visibility 0.15s ease;
-}
-
-.info-tooltip-floating.visible {
-    opacity: 1;
-    visibility: visible;
-}
diff --git a/static/css/snippets.css b/static/css/snippets.css
deleted file mode 100644
index 373de9f305..0000000000
--- a/static/css/snippets.css
+++ /dev/null
@@ -1,29 +0,0 @@
-/**
- * Copyright (C) 2026 Percona LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- */
-
-.snippets-tbl .approval-icon-cell>i {
-    font-size: 1.5em;
-    vertical-align: middle;
-}
-
-.snippets-tbl td {
-    padding: 10px;
-}
-
-.snippet-description-col {
-    max-width: 450px;
-}
diff --git a/static/css/tasks.css b/static/css/tasks.css
deleted file mode 100644
index f495c1aad6..0000000000
--- a/static/css/tasks.css
+++ /dev/null
@@ -1,49 +0,0 @@
-/* Copyright (C) 2026 Percona LLC                                               */
-/*                                                                              */
-/* This program is free software: you can redistribute it and/or modify         */
-/* it under the terms of the GNU Affero General Public License as published by  */
-/* the Free Software Foundation, either version 3 of the License, or            */
-/* (at your option) any later version.                                          */
-/*                                                                              */
-/* This program is distributed in the hope that it will be useful,              */
-/* but WITHOUT ANY WARRANTY; without even the implied warranty of               */
-/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the                 */
-/* GNU Affero General Public License for more details.                          */
-/*                                                                              */
-/* You should have received a copy of the GNU Affero General Public License     */
-/* along with this program. If not, see .        */
-
-.tasks-table-container {
-    border: 1px solid var(--lineDivider);
-    border-radius: 10px;
-}
-
-.tasks-table {
-    border: none;
-    margin-bottom: 0;
-}
-
-.tasks-table td,
-.tasks-table th {
-    padding: 0.8em 1em;
-}
-
-.tasks-table th {
-    font-weight: 600;
-}
-
-.previous-log-row {
-    border-bottom: none;
-}
-
-.previous-log-row td {
-    padding-bottom: .4em !important;
-}
-
-.log-row td {
-    padding: 0 0 .2em .8em !important
-}
-
-.no-tasks {
-    text-align: center;
-}
diff --git a/static/css/vendor/select2.min.css b/static/css/vendor/select2.min.css
deleted file mode 100644
index 7c18ad59df..0000000000
--- a/static/css/vendor/select2.min.css
+++ /dev/null
@@ -1 +0,0 @@
-.select2-container{box-sizing:border-box;display:inline-block;margin:0;position:relative;vertical-align:middle}.select2-container .select2-selection--single{box-sizing:border-box;cursor:pointer;display:block;height:28px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--single .select2-selection__rendered{display:block;padding-left:8px;padding-right:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-selection--single .select2-selection__clear{position:relative}.select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered{padding-right:8px;padding-left:20px}.select2-container .select2-selection--multiple{box-sizing:border-box;cursor:pointer;display:block;min-height:32px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--multiple .select2-selection__rendered{display:inline-block;overflow:hidden;padding-left:8px;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-search--inline{float:left}.select2-container .select2-search--inline .select2-search__field{box-sizing:border-box;border:none;font-size:100%;margin-top:5px;padding:0}.select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-dropdown{background-color:white;border:1px solid #aaa;border-radius:4px;box-sizing:border-box;display:block;position:absolute;left:-100000px;width:100%;z-index:1051}.select2-results{display:block}.select2-results__options{list-style:none;margin:0;padding:0}.select2-results__option{padding:6px;user-select:none;-webkit-user-select:none}.select2-results__option[aria-selected]{cursor:pointer}.select2-container--open .select2-dropdown{left:0}.select2-container--open .select2-dropdown--above{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--open .select2-dropdown--below{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-search--dropdown{display:block;padding:4px}.select2-search--dropdown .select2-search__field{padding:4px;width:100%;box-sizing:border-box}.select2-search--dropdown .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-search--dropdown.select2-search--hide{display:none}.select2-close-mask{border:0;margin:0;padding:0;display:block;position:fixed;left:0;top:0;min-height:100%;min-width:100%;height:auto;width:auto;opacity:0;z-index:99;background-color:#fff;filter:alpha(opacity=0)}.select2-hidden-accessible{border:0 !important;clip:rect(0 0 0 0) !important;-webkit-clip-path:inset(50%) !important;clip-path:inset(50%) !important;height:1px !important;overflow:hidden !important;padding:0 !important;position:absolute !important;width:1px !important;white-space:nowrap !important}.select2-container--default .select2-selection--single{background-color:#fff;border:1px solid #aaa;border-radius:4px}.select2-container--default .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--default .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold}.select2-container--default .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--default .select2-selection--single .select2-selection__arrow{height:26px;position:absolute;top:1px;right:1px;width:20px}.select2-container--default .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow{left:1px;right:auto}.select2-container--default.select2-container--disabled .select2-selection--single{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear{display:none}.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--default .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text}.select2-container--default .select2-selection--multiple .select2-selection__rendered{box-sizing:border-box;list-style:none;margin:0;padding:0 5px;width:100%}.select2-container--default .select2-selection--multiple .select2-selection__rendered li{list-style:none}.select2-container--default .select2-selection--multiple .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-top:5px;margin-right:10px;padding:1px}.select2-container--default .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove{color:#999;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover{color:#333}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice,.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-search--inline{float:right}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice{margin-left:5px;margin-right:auto}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--default.select2-container--focus .select2-selection--multiple{border:solid black 1px;outline:0}.select2-container--default.select2-container--disabled .select2-selection--multiple{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection__choice__remove{display:none}.select2-container--default.select2-container--open.select2-container--above .select2-selection--single,.select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple{border-top-left-radius:0;border-top-right-radius:0}.select2-container--default.select2-container--open.select2-container--below .select2-selection--single,.select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--default .select2-search--dropdown .select2-search__field{border:1px solid #aaa}.select2-container--default .select2-search--inline .select2-search__field{background:transparent;border:none;outline:0;box-shadow:none;-webkit-appearance:textfield}.select2-container--default .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--default .select2-results__option[role=group]{padding:0}.select2-container--default .select2-results__option[aria-disabled=true]{color:#999}.select2-container--default .select2-results__option[aria-selected=true]{background-color:#ddd}.select2-container--default .select2-results__option .select2-results__option{padding-left:1em}.select2-container--default .select2-results__option .select2-results__option .select2-results__group{padding-left:0}.select2-container--default .select2-results__option .select2-results__option .select2-results__option{margin-left:-1em;padding-left:2em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-2em;padding-left:3em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-3em;padding-left:4em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-4em;padding-left:5em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-5em;padding-left:6em}.select2-container--default .select2-results__option--highlighted[aria-selected]{background-color:#5897fb;color:white}.select2-container--default .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic .select2-selection--single{background-color:#f7f7f7;border:1px solid #aaa;border-radius:4px;outline:0;background-image:-webkit-linear-gradient(top, #fff 50%, #eee 100%);background-image:-o-linear-gradient(top, #fff 50%, #eee 100%);background-image:linear-gradient(to bottom, #fff 50%, #eee 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic .select2-selection--single:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--classic .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-right:10px}.select2-container--classic .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--classic .select2-selection--single .select2-selection__arrow{background-color:#ddd;border:none;border-left:1px solid #aaa;border-top-right-radius:4px;border-bottom-right-radius:4px;height:26px;position:absolute;top:1px;right:1px;width:20px;background-image:-webkit-linear-gradient(top, #eee 50%, #ccc 100%);background-image:-o-linear-gradient(top, #eee 50%, #ccc 100%);background-image:linear-gradient(to bottom, #eee 50%, #ccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0)}.select2-container--classic .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow{border:none;border-right:1px solid #aaa;border-radius:0;border-top-left-radius:4px;border-bottom-left-radius:4px;left:1px;right:auto}.select2-container--classic.select2-container--open .select2-selection--single{border:1px solid #5897fb}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow{background:transparent;border:none}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single{border-top:none;border-top-left-radius:0;border-top-right-radius:0;background-image:-webkit-linear-gradient(top, #fff 0%, #eee 50%);background-image:-o-linear-gradient(top, #fff 0%, #eee 50%);background-image:linear-gradient(to bottom, #fff 0%, #eee 50%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0;background-image:-webkit-linear-gradient(top, #eee 50%, #fff 100%);background-image:-o-linear-gradient(top, #eee 50%, #fff 100%);background-image:linear-gradient(to bottom, #eee 50%, #fff 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0)}.select2-container--classic .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text;outline:0}.select2-container--classic .select2-selection--multiple:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--multiple .select2-selection__rendered{list-style:none;margin:0;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__clear{display:none}.select2-container--classic .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove{color:#888;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover{color:#555}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice{float:right;margin-left:5px;margin-right:auto}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--classic.select2-container--open .select2-selection--multiple{border:1px solid #5897fb}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--classic .select2-search--dropdown .select2-search__field{border:1px solid #aaa;outline:0}.select2-container--classic .select2-search--inline .select2-search__field{outline:0;box-shadow:none}.select2-container--classic .select2-dropdown{background-color:#fff;border:1px solid transparent}.select2-container--classic .select2-dropdown--above{border-bottom:none}.select2-container--classic .select2-dropdown--below{border-top:none}.select2-container--classic .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--classic .select2-results__option[role=group]{padding:0}.select2-container--classic .select2-results__option[aria-disabled=true]{color:grey}.select2-container--classic .select2-results__option--highlighted[aria-selected]{background-color:#3875d7;color:#fff}.select2-container--classic .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic.select2-container--open .select2-dropdown{border-color:#5897fb}
diff --git a/static/fonts/MaterialSymbolsOutlined[FILL,GRAD,opsz,wght].ttf b/static/fonts/MaterialSymbolsOutlined[FILL,GRAD,opsz,wght].ttf
deleted file mode 100644
index 2bc9005f5a..0000000000
Binary files a/static/fonts/MaterialSymbolsOutlined[FILL,GRAD,opsz,wght].ttf and /dev/null differ
diff --git a/static/fonts/MaterialSymbolsOutlined[FILL,GRAD,opsz,wght].woff2 b/static/fonts/MaterialSymbolsOutlined[FILL,GRAD,opsz,wght].woff2
deleted file mode 100644
index 8c20aac9fb..0000000000
Binary files a/static/fonts/MaterialSymbolsOutlined[FILL,GRAD,opsz,wght].woff2 and /dev/null differ
diff --git a/static/fonts/Poppins-Black.ttf b/static/fonts/Poppins-Black.ttf
deleted file mode 100644
index 71c0f995ee..0000000000
Binary files a/static/fonts/Poppins-Black.ttf and /dev/null differ
diff --git a/static/fonts/Poppins-BlackItalic.ttf b/static/fonts/Poppins-BlackItalic.ttf
deleted file mode 100644
index 7aeb58bd1b..0000000000
Binary files a/static/fonts/Poppins-BlackItalic.ttf and /dev/null differ
diff --git a/static/fonts/Poppins-Bold.ttf b/static/fonts/Poppins-Bold.ttf
deleted file mode 100644
index 00559eeb29..0000000000
Binary files a/static/fonts/Poppins-Bold.ttf and /dev/null differ
diff --git a/static/fonts/Poppins-BoldItalic.ttf b/static/fonts/Poppins-BoldItalic.ttf
deleted file mode 100644
index e61e8e88bd..0000000000
Binary files a/static/fonts/Poppins-BoldItalic.ttf and /dev/null differ
diff --git a/static/fonts/Poppins-ExtraBold.ttf b/static/fonts/Poppins-ExtraBold.ttf
deleted file mode 100644
index df7093608a..0000000000
Binary files a/static/fonts/Poppins-ExtraBold.ttf and /dev/null differ
diff --git a/static/fonts/Poppins-ExtraBoldItalic.ttf b/static/fonts/Poppins-ExtraBoldItalic.ttf
deleted file mode 100644
index 14d2b375dc..0000000000
Binary files a/static/fonts/Poppins-ExtraBoldItalic.ttf and /dev/null differ
diff --git a/static/fonts/Poppins-ExtraLight.ttf b/static/fonts/Poppins-ExtraLight.ttf
deleted file mode 100644
index e76ec69a65..0000000000
Binary files a/static/fonts/Poppins-ExtraLight.ttf and /dev/null differ
diff --git a/static/fonts/Poppins-ExtraLightItalic.ttf b/static/fonts/Poppins-ExtraLightItalic.ttf
deleted file mode 100644
index 89513d9469..0000000000
Binary files a/static/fonts/Poppins-ExtraLightItalic.ttf and /dev/null differ
diff --git a/static/fonts/Poppins-Italic.ttf b/static/fonts/Poppins-Italic.ttf
deleted file mode 100644
index 12b7b3c40b..0000000000
Binary files a/static/fonts/Poppins-Italic.ttf and /dev/null differ
diff --git a/static/fonts/Poppins-Light.ttf b/static/fonts/Poppins-Light.ttf
deleted file mode 100644
index bc36bcc242..0000000000
Binary files a/static/fonts/Poppins-Light.ttf and /dev/null differ
diff --git a/static/fonts/Poppins-LightItalic.ttf b/static/fonts/Poppins-LightItalic.ttf
deleted file mode 100644
index 9e70be6a9e..0000000000
Binary files a/static/fonts/Poppins-LightItalic.ttf and /dev/null differ
diff --git a/static/fonts/Poppins-Medium.ttf b/static/fonts/Poppins-Medium.ttf
deleted file mode 100644
index 6bcdcc27f2..0000000000
Binary files a/static/fonts/Poppins-Medium.ttf and /dev/null differ
diff --git a/static/fonts/Poppins-MediumItalic.ttf b/static/fonts/Poppins-MediumItalic.ttf
deleted file mode 100644
index be67410fd0..0000000000
Binary files a/static/fonts/Poppins-MediumItalic.ttf and /dev/null differ
diff --git a/static/fonts/Poppins-Regular.ttf b/static/fonts/Poppins-Regular.ttf
deleted file mode 100644
index 9f0c71b70a..0000000000
Binary files a/static/fonts/Poppins-Regular.ttf and /dev/null differ
diff --git a/static/fonts/Poppins-SemiBold.ttf b/static/fonts/Poppins-SemiBold.ttf
deleted file mode 100644
index 74c726e327..0000000000
Binary files a/static/fonts/Poppins-SemiBold.ttf and /dev/null differ
diff --git a/static/fonts/Poppins-SemiBoldItalic.ttf b/static/fonts/Poppins-SemiBoldItalic.ttf
deleted file mode 100644
index 3e6c942233..0000000000
Binary files a/static/fonts/Poppins-SemiBoldItalic.ttf and /dev/null differ
diff --git a/static/fonts/Poppins-Thin.ttf b/static/fonts/Poppins-Thin.ttf
deleted file mode 100644
index 03e736613a..0000000000
Binary files a/static/fonts/Poppins-Thin.ttf and /dev/null differ
diff --git a/static/fonts/Poppins-ThinItalic.ttf b/static/fonts/Poppins-ThinItalic.ttf
deleted file mode 100644
index e26db5dd3d..0000000000
Binary files a/static/fonts/Poppins-ThinItalic.ttf and /dev/null differ
diff --git a/static/fonts/Roboto-Italic-VariableFont_wdth,wght.ttf b/static/fonts/Roboto-Italic-VariableFont_wdth,wght.ttf
deleted file mode 100644
index 978e53a7de..0000000000
Binary files a/static/fonts/Roboto-Italic-VariableFont_wdth,wght.ttf and /dev/null differ
diff --git a/static/fonts/Roboto-VariableFont_wdth,wght.ttf b/static/fonts/Roboto-VariableFont_wdth,wght.ttf
deleted file mode 100644
index bba55f616c..0000000000
Binary files a/static/fonts/Roboto-VariableFont_wdth,wght.ttf and /dev/null differ
diff --git a/static/fonts/RobotoMono-Italic-VariableFont_wght.ttf b/static/fonts/RobotoMono-Italic-VariableFont_wght.ttf
deleted file mode 100644
index 1a4d6948d6..0000000000
Binary files a/static/fonts/RobotoMono-Italic-VariableFont_wght.ttf and /dev/null differ
diff --git a/static/fonts/RobotoMono-VariableFont_wght.ttf b/static/fonts/RobotoMono-VariableFont_wght.ttf
deleted file mode 100644
index fc02de43ec..0000000000
Binary files a/static/fonts/RobotoMono-VariableFont_wght.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto-Black.ttf b/static/fonts/roboto static/Roboto-Black.ttf
deleted file mode 100644
index d51221af2b..0000000000
Binary files a/static/fonts/roboto static/Roboto-Black.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto-BlackItalic.ttf b/static/fonts/roboto static/Roboto-BlackItalic.ttf
deleted file mode 100644
index c71c549d3f..0000000000
Binary files a/static/fonts/roboto static/Roboto-BlackItalic.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto-Bold.ttf b/static/fonts/roboto static/Roboto-Bold.ttf
deleted file mode 100644
index 9d7cf220f9..0000000000
Binary files a/static/fonts/roboto static/Roboto-Bold.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto-BoldItalic.ttf b/static/fonts/roboto static/Roboto-BoldItalic.ttf
deleted file mode 100644
index f73d681263..0000000000
Binary files a/static/fonts/roboto static/Roboto-BoldItalic.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto-ExtraBold.ttf b/static/fonts/roboto static/Roboto-ExtraBold.ttf
deleted file mode 100644
index 7092a880bb..0000000000
Binary files a/static/fonts/roboto static/Roboto-ExtraBold.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto-ExtraBoldItalic.ttf b/static/fonts/roboto static/Roboto-ExtraBoldItalic.ttf
deleted file mode 100644
index a5536f5d54..0000000000
Binary files a/static/fonts/roboto static/Roboto-ExtraBoldItalic.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto-ExtraLight.ttf b/static/fonts/roboto static/Roboto-ExtraLight.ttf
deleted file mode 100644
index 75608c6642..0000000000
Binary files a/static/fonts/roboto static/Roboto-ExtraLight.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto-ExtraLightItalic.ttf b/static/fonts/roboto static/Roboto-ExtraLightItalic.ttf
deleted file mode 100644
index 23dbbef7ad..0000000000
Binary files a/static/fonts/roboto static/Roboto-ExtraLightItalic.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto-Italic.ttf b/static/fonts/roboto static/Roboto-Italic.ttf
deleted file mode 100644
index c3abaefb28..0000000000
Binary files a/static/fonts/roboto static/Roboto-Italic.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto-Light.ttf b/static/fonts/roboto static/Roboto-Light.ttf
deleted file mode 100644
index 6fcd5f964d..0000000000
Binary files a/static/fonts/roboto static/Roboto-Light.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto-LightItalic.ttf b/static/fonts/roboto static/Roboto-LightItalic.ttf
deleted file mode 100644
index a6e504709f..0000000000
Binary files a/static/fonts/roboto static/Roboto-LightItalic.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto-Medium.ttf b/static/fonts/roboto static/Roboto-Medium.ttf
deleted file mode 100644
index d629e98489..0000000000
Binary files a/static/fonts/roboto static/Roboto-Medium.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto-MediumItalic.ttf b/static/fonts/roboto static/Roboto-MediumItalic.ttf
deleted file mode 100644
index ef9ed1b441..0000000000
Binary files a/static/fonts/roboto static/Roboto-MediumItalic.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto-Regular.ttf b/static/fonts/roboto static/Roboto-Regular.ttf
deleted file mode 100644
index 7e3bb2f8ce..0000000000
Binary files a/static/fonts/roboto static/Roboto-Regular.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto-SemiBold.ttf b/static/fonts/roboto static/Roboto-SemiBold.ttf
deleted file mode 100644
index 3f348341cb..0000000000
Binary files a/static/fonts/roboto static/Roboto-SemiBold.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto-SemiBoldItalic.ttf b/static/fonts/roboto static/Roboto-SemiBoldItalic.ttf
deleted file mode 100644
index 132cca1602..0000000000
Binary files a/static/fonts/roboto static/Roboto-SemiBoldItalic.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto-Thin.ttf b/static/fonts/roboto static/Roboto-Thin.ttf
deleted file mode 100644
index 6ee97b8895..0000000000
Binary files a/static/fonts/roboto static/Roboto-Thin.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto-ThinItalic.ttf b/static/fonts/roboto static/Roboto-ThinItalic.ttf
deleted file mode 100644
index 0381198d34..0000000000
Binary files a/static/fonts/roboto static/Roboto-ThinItalic.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_Condensed-Black.ttf b/static/fonts/roboto static/Roboto_Condensed-Black.ttf
deleted file mode 100644
index 7529d1b905..0000000000
Binary files a/static/fonts/roboto static/Roboto_Condensed-Black.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_Condensed-BlackItalic.ttf b/static/fonts/roboto static/Roboto_Condensed-BlackItalic.ttf
deleted file mode 100644
index 0c31e9f57c..0000000000
Binary files a/static/fonts/roboto static/Roboto_Condensed-BlackItalic.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_Condensed-Bold.ttf b/static/fonts/roboto static/Roboto_Condensed-Bold.ttf
deleted file mode 100644
index c3ccd49158..0000000000
Binary files a/static/fonts/roboto static/Roboto_Condensed-Bold.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_Condensed-BoldItalic.ttf b/static/fonts/roboto static/Roboto_Condensed-BoldItalic.ttf
deleted file mode 100644
index d269187b4b..0000000000
Binary files a/static/fonts/roboto static/Roboto_Condensed-BoldItalic.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_Condensed-ExtraBold.ttf b/static/fonts/roboto static/Roboto_Condensed-ExtraBold.ttf
deleted file mode 100644
index 782442a747..0000000000
Binary files a/static/fonts/roboto static/Roboto_Condensed-ExtraBold.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_Condensed-ExtraBoldItalic.ttf b/static/fonts/roboto static/Roboto_Condensed-ExtraBoldItalic.ttf
deleted file mode 100644
index aeff7c299f..0000000000
Binary files a/static/fonts/roboto static/Roboto_Condensed-ExtraBoldItalic.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_Condensed-ExtraLight.ttf b/static/fonts/roboto static/Roboto_Condensed-ExtraLight.ttf
deleted file mode 100644
index 16a1560e8d..0000000000
Binary files a/static/fonts/roboto static/Roboto_Condensed-ExtraLight.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_Condensed-ExtraLightItalic.ttf b/static/fonts/roboto static/Roboto_Condensed-ExtraLightItalic.ttf
deleted file mode 100644
index 0f6fe709cd..0000000000
Binary files a/static/fonts/roboto static/Roboto_Condensed-ExtraLightItalic.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_Condensed-Italic.ttf b/static/fonts/roboto static/Roboto_Condensed-Italic.ttf
deleted file mode 100644
index 3b387eb859..0000000000
Binary files a/static/fonts/roboto static/Roboto_Condensed-Italic.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_Condensed-Light.ttf b/static/fonts/roboto static/Roboto_Condensed-Light.ttf
deleted file mode 100644
index e70c357377..0000000000
Binary files a/static/fonts/roboto static/Roboto_Condensed-Light.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_Condensed-LightItalic.ttf b/static/fonts/roboto static/Roboto_Condensed-LightItalic.ttf
deleted file mode 100644
index 9f623e0021..0000000000
Binary files a/static/fonts/roboto static/Roboto_Condensed-LightItalic.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_Condensed-Medium.ttf b/static/fonts/roboto static/Roboto_Condensed-Medium.ttf
deleted file mode 100644
index dd2842b549..0000000000
Binary files a/static/fonts/roboto static/Roboto_Condensed-Medium.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_Condensed-MediumItalic.ttf b/static/fonts/roboto static/Roboto_Condensed-MediumItalic.ttf
deleted file mode 100644
index 80ff64eca9..0000000000
Binary files a/static/fonts/roboto static/Roboto_Condensed-MediumItalic.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_Condensed-Regular.ttf b/static/fonts/roboto static/Roboto_Condensed-Regular.ttf
deleted file mode 100644
index 5af42d4733..0000000000
Binary files a/static/fonts/roboto static/Roboto_Condensed-Regular.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_Condensed-SemiBold.ttf b/static/fonts/roboto static/Roboto_Condensed-SemiBold.ttf
deleted file mode 100644
index 4297f17386..0000000000
Binary files a/static/fonts/roboto static/Roboto_Condensed-SemiBold.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_Condensed-SemiBoldItalic.ttf b/static/fonts/roboto static/Roboto_Condensed-SemiBoldItalic.ttf
deleted file mode 100644
index 6cb4656c66..0000000000
Binary files a/static/fonts/roboto static/Roboto_Condensed-SemiBoldItalic.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_Condensed-Thin.ttf b/static/fonts/roboto static/Roboto_Condensed-Thin.ttf
deleted file mode 100644
index 1ccebccfea..0000000000
Binary files a/static/fonts/roboto static/Roboto_Condensed-Thin.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_Condensed-ThinItalic.ttf b/static/fonts/roboto static/Roboto_Condensed-ThinItalic.ttf
deleted file mode 100644
index e58e966e13..0000000000
Binary files a/static/fonts/roboto static/Roboto_Condensed-ThinItalic.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_SemiCondensed-Black.ttf b/static/fonts/roboto static/Roboto_SemiCondensed-Black.ttf
deleted file mode 100644
index 8eedb64afa..0000000000
Binary files a/static/fonts/roboto static/Roboto_SemiCondensed-Black.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_SemiCondensed-BlackItalic.ttf b/static/fonts/roboto static/Roboto_SemiCondensed-BlackItalic.ttf
deleted file mode 100644
index 19a509641c..0000000000
Binary files a/static/fonts/roboto static/Roboto_SemiCondensed-BlackItalic.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_SemiCondensed-Bold.ttf b/static/fonts/roboto static/Roboto_SemiCondensed-Bold.ttf
deleted file mode 100644
index 98d7b0d5ad..0000000000
Binary files a/static/fonts/roboto static/Roboto_SemiCondensed-Bold.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_SemiCondensed-BoldItalic.ttf b/static/fonts/roboto static/Roboto_SemiCondensed-BoldItalic.ttf
deleted file mode 100644
index 8604aee4a8..0000000000
Binary files a/static/fonts/roboto static/Roboto_SemiCondensed-BoldItalic.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_SemiCondensed-ExtraBold.ttf b/static/fonts/roboto static/Roboto_SemiCondensed-ExtraBold.ttf
deleted file mode 100644
index 36423c33fb..0000000000
Binary files a/static/fonts/roboto static/Roboto_SemiCondensed-ExtraBold.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_SemiCondensed-ExtraBoldItalic.ttf b/static/fonts/roboto static/Roboto_SemiCondensed-ExtraBoldItalic.ttf
deleted file mode 100644
index b40ce774d1..0000000000
Binary files a/static/fonts/roboto static/Roboto_SemiCondensed-ExtraBoldItalic.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_SemiCondensed-ExtraLight.ttf b/static/fonts/roboto static/Roboto_SemiCondensed-ExtraLight.ttf
deleted file mode 100644
index e1c25a01c0..0000000000
Binary files a/static/fonts/roboto static/Roboto_SemiCondensed-ExtraLight.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_SemiCondensed-ExtraLightItalic.ttf b/static/fonts/roboto static/Roboto_SemiCondensed-ExtraLightItalic.ttf
deleted file mode 100644
index 929a0938bf..0000000000
Binary files a/static/fonts/roboto static/Roboto_SemiCondensed-ExtraLightItalic.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_SemiCondensed-Italic.ttf b/static/fonts/roboto static/Roboto_SemiCondensed-Italic.ttf
deleted file mode 100644
index 23454ff1d8..0000000000
Binary files a/static/fonts/roboto static/Roboto_SemiCondensed-Italic.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_SemiCondensed-Light.ttf b/static/fonts/roboto static/Roboto_SemiCondensed-Light.ttf
deleted file mode 100644
index b9aedcd49d..0000000000
Binary files a/static/fonts/roboto static/Roboto_SemiCondensed-Light.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_SemiCondensed-LightItalic.ttf b/static/fonts/roboto static/Roboto_SemiCondensed-LightItalic.ttf
deleted file mode 100644
index c096473e92..0000000000
Binary files a/static/fonts/roboto static/Roboto_SemiCondensed-LightItalic.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_SemiCondensed-Medium.ttf b/static/fonts/roboto static/Roboto_SemiCondensed-Medium.ttf
deleted file mode 100644
index e9c34d6dd9..0000000000
Binary files a/static/fonts/roboto static/Roboto_SemiCondensed-Medium.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_SemiCondensed-MediumItalic.ttf b/static/fonts/roboto static/Roboto_SemiCondensed-MediumItalic.ttf
deleted file mode 100644
index ab34b70cdb..0000000000
Binary files a/static/fonts/roboto static/Roboto_SemiCondensed-MediumItalic.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_SemiCondensed-Regular.ttf b/static/fonts/roboto static/Roboto_SemiCondensed-Regular.ttf
deleted file mode 100644
index 36109bacf2..0000000000
Binary files a/static/fonts/roboto static/Roboto_SemiCondensed-Regular.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_SemiCondensed-SemiBold.ttf b/static/fonts/roboto static/Roboto_SemiCondensed-SemiBold.ttf
deleted file mode 100644
index 6d10b333a0..0000000000
Binary files a/static/fonts/roboto static/Roboto_SemiCondensed-SemiBold.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_SemiCondensed-SemiBoldItalic.ttf b/static/fonts/roboto static/Roboto_SemiCondensed-SemiBoldItalic.ttf
deleted file mode 100644
index e88bc4af43..0000000000
Binary files a/static/fonts/roboto static/Roboto_SemiCondensed-SemiBoldItalic.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_SemiCondensed-Thin.ttf b/static/fonts/roboto static/Roboto_SemiCondensed-Thin.ttf
deleted file mode 100644
index 8ed8d79cb8..0000000000
Binary files a/static/fonts/roboto static/Roboto_SemiCondensed-Thin.ttf and /dev/null differ
diff --git a/static/fonts/roboto static/Roboto_SemiCondensed-ThinItalic.ttf b/static/fonts/roboto static/Roboto_SemiCondensed-ThinItalic.ttf
deleted file mode 100644
index 81afeea53c..0000000000
Binary files a/static/fonts/roboto static/Roboto_SemiCondensed-ThinItalic.ttf and /dev/null differ
diff --git a/static/img/percona-fav.svg b/static/img/percona-fav.svg
deleted file mode 100755
index e671a464bb..0000000000
--- a/static/img/percona-fav.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
-
-
-
-
-
diff --git a/static/js/alert-troubleshooting.js b/static/js/alert-troubleshooting.js
deleted file mode 100644
index ab980cd239..0000000000
--- a/static/js/alert-troubleshooting.js
+++ /dev/null
@@ -1,61 +0,0 @@
-/**
- * Copyright (C) 2026 Percona LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- */
-
-/* Animate 
accordion sections via grid-template-rows transitions. */ - -document.addEventListener("DOMContentLoaded", function() { - document.querySelectorAll(".at-section").forEach(function(details) { - var summary = details.querySelector(".at-section-header"); - var body = details.querySelector(".at-section-body"); - if (!summary || !body) return; - - summary.addEventListener("click", function(e) { - e.preventDefault(); - - if (details.open) { - // Collapse: animate grid rows to 0fr, then remove open - body.style.gridTemplateRows = "0fr"; - body.addEventListener( - "transitionend", - function() { - details.open = false; - body.style.gridTemplateRows = ""; - }, { - once: true - } - ); - } else { - // Expand: set open, force 0fr, then animate to 1fr - details.open = true; - body.style.gridTemplateRows = "0fr"; - requestAnimationFrame(function() { - requestAnimationFrame(function() { - body.style.gridTemplateRows = "1fr"; - body.addEventListener( - "transitionend", - function() { - body.style.gridTemplateRows = ""; - }, { - once: true - } - ); - }); - }); - } - }); - }); -}); diff --git a/static/js/alerts.js b/static/js/alerts.js deleted file mode 100644 index 308d861ae4..0000000000 --- a/static/js/alerts.js +++ /dev/null @@ -1,625 +0,0 @@ -/* - * Copyright (C) 2026 Percona LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - */ - -document.addEventListener('DOMContentLoaded', function() { - var buttons = document.querySelectorAll('.toggle-btn-group .toggle-btn'); - var rows = document.querySelectorAll('.alerts-table tbody tr'); - var selectAll = document.getElementById('select-all-alerts'); - var pushBtn = document.getElementById('push-to-pmm'); - var resultsContainer = document.getElementById('push-results'); - - buttons.forEach(function(btn) { - btn.addEventListener('click', function() { - buttons.forEach(function(b) { - b.classList.remove('active'); - }); - btn.classList.add('active'); - - var filter = btn.getAttribute('data-filter'); - rows.forEach(function(row) { - if (filter === 'all' || row.getAttribute('data-service-type') === filter) { - row.style.display = ''; - } else { - row.style.display = 'none'; - } - }); - - if (selectAll) selectAll.checked = false; - updatePushButton(); - }); - }); - - if (selectAll) { - selectAll.addEventListener('change', function() { - var checked = selectAll.checked; - rows.forEach(function(row) { - if (row.style.display !== 'none') { - var cb = row.querySelector('.alert-checkbox'); - if (cb) cb.checked = checked; - } - }); - updatePushButton(); - }); - - function updateSelectAll() { - var visible = []; - rows.forEach(function(row) { - if (row.style.display !== 'none') { - var cb = row.querySelector('.alert-checkbox'); - if (cb) visible.push(cb); - } - }); - var allChecked = visible.length > 0 && visible.every(function(cb) { - return cb.checked; - }); - var someChecked = visible.some(function(cb) { - return cb.checked; - }); - selectAll.checked = allChecked; - selectAll.indeterminate = !allChecked && someChecked; - } - - rows.forEach(function(row) { - var cb = row.querySelector('.alert-checkbox'); - if (cb) { - cb.addEventListener('change', function() { - updateSelectAll(); - updatePushButton(); - }); - } - }); - } - - function getSelectedNames() { - var names = []; - rows.forEach(function(row) { - var cb = row.querySelector('.alert-checkbox'); - if (cb && cb.checked) names.push(cb.value); - }); - return names; - } - - function updatePushButton() { - if (!pushBtn) return; - var selected = getSelectedNames(); - if (selected.length > 0) { - pushBtn.style.display = ''; - pushBtn.disabled = false; - } else { - pushBtn.style.display = 'none'; - pushBtn.disabled = true; - } - } - - function getCsrfToken() { - var input = document.querySelector('input[name="csrf-token"]'); - if (input) return input.value; - return ''; - } - - function updateBadge(templateName, newStatus) { - rows.forEach(function(row) { - var cb = row.querySelector('.alert-checkbox'); - if (cb && cb.value === templateName) { - var badges = row.querySelectorAll('.alert-badge'); - badges.forEach(function(badge) { - if (badge.classList.contains('badge-absent') || - badge.classList.contains('badge-unknown')) { - if (newStatus === 'success') { - badge.className = 'alert-badge badge-present'; - badge.textContent = 'Present'; - } - } - }); - } - }); - } - - function clearChildren(node) { - while (node.firstChild) node.removeChild(node.firstChild); - } - - function clearResults() { - if (!resultsContainer) return; - clearChildren(resultsContainer); - } - - function showResults(results) { - if (!resultsContainer) return; - clearResults(); - results.forEach(function(r) { - var el = document.createElement('div'); - el.className = 'push-result push-result-' + r.status; - el.textContent = r.name + ': ' + r.message; - resultsContainer.appendChild(el); - }); - resultsContainer.style.display = ''; - } - - if (pushBtn) { - pushBtn.addEventListener('click', function() { - var selected = getSelectedNames(); - if (selected.length === 0) return; - - pushBtn.disabled = true; - pushBtn.textContent = 'Pushing...'; - - var formData = new FormData(); - selected.forEach(function(name) { - formData.append('selected_templates', name); - }); - formData.append('csrf-token', getCsrfToken()); - - fetch('/alerts/push', { - method: 'POST', - body: formData, - }).then(function(response) { - return response.json(); - }).then(function(data) { - if (data.results) { - showResults(data.results); - data.results.forEach(function(r) { - if (r.status === 'success') { - updateBadge(r.name, 'success'); - } - }); - } else if (data.error || data.detail) { - showResults([{ - name: 'Error', - status: 'error', - message: data.error || data.detail - }]); - } - }).catch(function(err) { - showResults([{ - name: 'Error', - status: 'error', - message: 'Network error: ' + err.message - }]); - }).finally(function() { - pushBtn.disabled = false; - pushBtn.textContent = 'Push to PMM'; - updatePushButton(); - }); - }); - } - - updatePushButton(); - - // PagerDuty widget - var pdWidget = document.getElementById('pagerduty-widget'); - - function showFeedback(message, isError) { - var feedback = document.getElementById('pd-feedback'); - if (!feedback) return; - feedback.textContent = message; - feedback.className = 'pd-feedback ' + (isError ? 'error' : 'success'); - } - - function getPdCsrfToken() { - var input = pdWidget.querySelector('[name="csrf-token"]'); - return input ? input.value : ''; - } - - function el(tag, attrs, children) { - var node = document.createElement(tag); - if (attrs) { - Object.keys(attrs).forEach(function(k) { - if (k === 'className') { - node.className = attrs[k]; - } else if (k === 'textContent') { - node.textContent = attrs[k]; - } else { - node.setAttribute(k, attrs[k]); - } - }); - } - (children || []).forEach(function(c) { - node.appendChild(c); - }); - return node; - } - - function buildFeedbackDiv() { - return el('div', { - id: 'pd-feedback', - className: 'pd-feedback' - }); - } - - function buildCsrfInput(csrfToken) { - return el('input', { - type: 'hidden', - name: 'csrf-token', - value: csrfToken - }); - } - - function renderConfigured(csrfToken) { - clearChildren(pdWidget); - - pdWidget.appendChild(el('h4', { - textContent: 'PagerDuty Integration' - })); - - var badge = el('span', { - className: 'alert-badge badge-present', - textContent: 'Configured' - }); - pdWidget.appendChild(el('div', { - className: 'pd-status' - }, [badge])); - - var form = el('form', { - id: 'pd-form', - className: 'pd-form' - }, [ - buildCsrfInput(csrfToken), - el('label', { - for: 'pd-integration-key', - textContent: 'Update Integration Key' - }), - el('input', { - type: 'password', - id: 'pd-integration-key', - name: 'integration_key', - placeholder: 'Enter new PagerDuty key' - }), - el('div', { - className: 'pd-form-actions' - }, [ - el('button', { - type: 'submit', - className: 'contained small', - textContent: 'Update' - }), - el('button', { - type: 'button', - className: 'text small pd-delete-btn', - id: 'pd-delete-btn', - textContent: 'Delete' - }) - ]) - ]); - pdWidget.appendChild(form); - pdWidget.appendChild(buildFeedbackDiv()); - - bindPdEvents(); - } - - function renderNotConfigured(csrfToken) { - clearChildren(pdWidget); - - pdWidget.appendChild(el('h4', { - textContent: 'PagerDuty Integration' - })); - pdWidget.appendChild(el('span', { - className: 'alert-badge badge-absent', - textContent: 'Not Configured' - })); - - var form = el('form', { - id: 'pd-form', - className: 'pd-form' - }, [ - buildCsrfInput(csrfToken), - el('label', { - for: 'pd-integration-key', - textContent: 'Integration Key' - }), - el('input', { - type: 'password', - id: 'pd-integration-key', - name: 'integration_key', - placeholder: 'Enter PagerDuty integration key', - required: 'required' - }), - el('button', { - type: 'submit', - className: 'contained small', - textContent: 'Create' - }) - ]); - pdWidget.appendChild(form); - pdWidget.appendChild(buildFeedbackDiv()); - - bindPdEvents(); - } - - function bindPdEvents() { - var form = document.getElementById('pd-form'); - var deleteBtn = document.getElementById('pd-delete-btn'); - - if (form) { - form.addEventListener('submit', handleSave); - } - if (deleteBtn) { - deleteBtn.addEventListener('click', handleDelete); - } - } - - function handleSave(e) { - e.preventDefault(); - var form = document.getElementById('pd-form'); - var csrfToken = getPdCsrfToken(); - var integrationKey = form.querySelector('[name="integration_key"]').value; - - if (!integrationKey) { - showFeedback('Integration key is required.', true); - return; - } - - var formData = new FormData(); - formData.append('integration_key', integrationKey); - formData.append('csrf-token', csrfToken); - - fetch('/alerts/pagerduty', { - method: 'POST', - headers: { - 'X-CSRF-Token': csrfToken - }, - body: formData - }).then(function(response) { - return response.json().then(function(data) { - return { - ok: response.ok, - data: data - }; - }); - }).then(function(result) { - if (result.ok) { - renderConfigured(csrfToken); - showFeedback('PagerDuty integration ' + result.data.status + ' successfully.', false); - } else { - showFeedback(result.data.error || 'An error occurred.', true); - } - }).catch(function() { - showFeedback('Network error. Please try again.', true); - }); - } - - function handleDelete() { - if (!confirm('Are you sure you want to delete the PagerDuty integration?')) { - return; - } - - var csrfToken = getPdCsrfToken(); - var formData = new FormData(); - formData.append('csrf-token', csrfToken); - - fetch('/alerts/pagerduty/delete', { - method: 'POST', - headers: { - 'X-CSRF-Token': csrfToken - }, - body: formData - }).then(function(response) { - return response.json().then(function(data) { - return { - ok: response.ok, - data: data - }; - }); - }).then(function(result) { - if (result.ok) { - renderNotConfigured(csrfToken); - showFeedback('PagerDuty integration deleted.', false); - } else { - showFeedback(result.data.error || 'An error occurred.', true); - } - }).catch(function() { - showFeedback('Network error. Please try again.', true); - }); - } - - if (pdWidget) { - bindPdEvents(); - } - - // Backup detail & restore functionality - var restoreDialog = document.getElementById('dialog-restore-confirm'); - var detailDialog = document.getElementById('dialog-backup-detail'); - var feedback = document.getElementById('restore-feedback'); - var pendingRestoreId = null; - - function getBackupCsrfToken() { - var input = document.getElementById('backup-csrf-token'); - return input ? input.value : ''; - } - - function buildDetailSection(title, items, renderItem) { - var section = el('div', { - className: 'backup-detail-section' - }, [ - el('h4', { - textContent: title + ' (' + items.length + ')' - }) - ]); - var list = el('ul'); - items.forEach(function(item) { - list.appendChild(renderItem(item)); - }); - section.appendChild(list); - return section; - } - - document.querySelectorAll('.backup-detail-btn').forEach(function(btn) { - btn.addEventListener('click', function() { - var backupId = btn.getAttribute('data-backup-id'); - var backupDate = btn.getAttribute('data-backup-date'); - var dateSpan = document.getElementById('detail-date'); - var content = document.getElementById('detail-content'); - - dateSpan.textContent = backupDate; - clearChildren(content); - content.appendChild(el('p', { - className: 'text-muted', - textContent: 'Loading\u2026' - })); - detailDialog.showModal(); - - fetch('/alerts/backups/' + backupId).then(function(response) { - if (!response.ok) throw new Error('Failed to load backup details'); - return response.json(); - }).then(function(data) { - clearChildren(content); - - if (data.templates.length) { - content.appendChild(buildDetailSection('Templates', data.templates, function(t) { - var li = el('li', { - textContent: t.name - }); - if (t.summary) { - li.appendChild(el('span', { - className: 'detail-type', - textContent: ' \u2014 ' + t.summary - })); - } - return li; - })); - } - - if (data.rules.length) { - content.appendChild(buildDetailSection('Rules', data.rules, function(r) { - return el('li', { - textContent: r.title - }); - })); - } - - if (data.contact_points.length) { - content.appendChild(buildDetailSection('Contact Points', data.contact_points, function(cp) { - var li = el('li', { - textContent: cp.name - }); - if (cp.type) { - li.appendChild(el('span', { - className: 'detail-type', - textContent: ' (' + cp.type + ')' - })); - } - return li; - })); - } - - if (data.folders.length) { - content.appendChild(buildDetailSection('Folders', data.folders, function(f) { - return el('li', { - textContent: f.title - }); - })); - } - - if (data.notification_policy_receiver) { - var policySection = el('div', { - className: 'backup-detail-section' - }, [ - el('h4', { - textContent: 'Notification Policy' - }) - ]); - var policyList = el('ul'); - var policyItem = el('li'); - policyItem.appendChild(document.createTextNode('Receiver: ')); - policyItem.appendChild(el('strong', { - textContent: data.notification_policy_receiver - })); - policyList.appendChild(policyItem); - policySection.appendChild(policyList); - content.appendChild(policySection); - } - - if (!content.hasChildNodes()) { - content.appendChild(el('p', { - className: 'text-muted', - textContent: 'This backup is empty.' - })); - } - }).catch(function(err) { - clearChildren(content); - content.appendChild(el('p', { - className: 'text-muted', - textContent: 'Error: ' + err.message - })); - }); - }); - }); - - document.querySelectorAll('.backup-restore-btn').forEach(function(btn) { - btn.addEventListener('click', function() { - pendingRestoreId = btn.getAttribute('data-backup-id'); - var backupDate = btn.getAttribute('data-backup-date'); - document.getElementById('restore-confirm-date').textContent = backupDate; - restoreDialog.showModal(); - }); - }); - - var confirmBtn = document.getElementById('restore-confirm-btn'); - if (confirmBtn) { - confirmBtn.addEventListener('click', function() { - if (!pendingRestoreId) return; - restoreDialog.close(); - - feedback.textContent = 'Restoring\u2026'; - feedback.className = 'restore-feedback'; - - var csrfToken = getBackupCsrfToken(); - var formData = new FormData(); - formData.append('backup_id', pendingRestoreId); - formData.append('csrf-token', csrfToken); - - fetch('/alerts/restore', { - method: 'POST', - body: formData, - headers: { - 'X-CSRF-Token': csrfToken - }, - }).then(function(response) { - if (!response.ok) { - return response.json().catch(function() { - return { - status: 'error', - message: 'Request failed with status ' + response.status - }; - }).then(function(data) { - if (data.status === 'error') return data; - return { - status: 'error', - message: data.message || data.error || 'Request failed' - }; - }); - } - return response.json(); - }).then(function(data) { - if (data.status === 'success') { - var d = data.details; - feedback.className = 'restore-feedback feedback-success'; - var msg = 'Restore complete: ' + - d.rules_deleted + ' rules deleted, ' + - d.rules_created + ' created'; - if (d.rules_skipped > 0) { - msg += ', ' + d.rules_skipped + ' skipped'; - } - msg += '. ' + - d.templates.created + ' templates created, ' + - d.templates.skipped + ' skipped.'; - feedback.textContent = msg; - } else { - feedback.className = 'restore-feedback feedback-error'; - feedback.textContent = 'Restore failed: ' + (data.message || data.error || 'Unknown error'); - } - }).catch(function(err) { - feedback.className = 'restore-feedback feedback-error'; - feedback.textContent = 'Restore failed: ' + err.message; - }).finally(function() { - pendingRestoreId = null; - }); - }); - } -}); diff --git a/static/js/app.js b/static/js/app.js deleted file mode 100644 index 2c8a6e7374..0000000000 --- a/static/js/app.js +++ /dev/null @@ -1,641 +0,0 @@ -/** - * Copyright (C) 2026 Percona LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -document.addEventListener('DOMContentLoaded', () => { - - const body = document.body; - - // ============================================= - // Sidebar behaviors - // ============================================= - // Select all sidebar links - const sidebarLinks = document.querySelectorAll('.sidebar .list-item'); - const currentPath = window.location.pathname; - - // Set the active sidebar link - sidebarLinks.forEach(link => { - if (link.target === '_blank') return; - - const linkPath = new URL(link.href, window.location.origin).pathname; - const currentPath = window.location.pathname; - - if (linkPath === '/' && currentPath === '/') { - link.classList.add('active'); - console.log("rool") - } else if ( - linkPath !== '/' && - (currentPath === linkPath || currentPath.startsWith(linkPath)) - ) { - link.classList.add('active'); - console.log("herer") - } - }); - - // ============================================= - // GUI theme toggle - // ============================================= - // Read localstorage to set the theme - const storedTheme = localStorage.getItem('theme'); - const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; - - // Set theme: use stored theme if available, otherwise use system preference. - const theme = storedTheme ? storedTheme : (systemPrefersDark ? 'dark' : 'light'); - body.setAttribute('data-theme', theme); - - // Select the theme toggle buttons. - // Give each toggle a distinguishing class (or data attribute). - const darkToggle = document.querySelector('.theme-change.dark'); - const lightToggle = document.querySelector('.theme-change.light'); - - if (darkToggle) { - darkToggle.addEventListener('click', (e) => { - e.preventDefault(); - body.setAttribute('data-theme', 'dark'); - localStorage.setItem('theme', 'dark'); - }); - } - - if (lightToggle) { - lightToggle.addEventListener('click', (e) => { - e.preventDefault(); - body.setAttribute('data-theme', 'light'); - localStorage.setItem('theme', 'light'); - }); - } - - // ============================================= - // Tab functionality - // ============================================= - // Select all tab buttons and tab panels - function initTabs(tabsContainer, panelsContainer, storageKey) { - const tabButtons = tabsContainer.querySelectorAll('[role="tab"]'); - const tabPanels = panelsContainer.querySelectorAll('[role="tabpanel"]'); - - // Restore stored state for this container - const savedTabId = localStorage.getItem(storageKey); - if (savedTabId) { - const savedTab = tabsContainer.querySelector(`[role="tab"][aria-controls="${savedTabId}"]`); - const savedPanel = panelsContainer.querySelector(`#${savedTabId}`); - if (savedTab && savedPanel) { - tabButtons.forEach(btn => btn.setAttribute('aria-selected', 'false')); - tabPanels.forEach(panel => panel.setAttribute('aria-hidden', 'true')); - savedTab.setAttribute('aria-selected', 'true'); - savedPanel.setAttribute('aria-hidden', 'false'); - } - } else if (tabButtons.length > 0) { - // If no saved state, activate the first tab. - tabButtons.forEach(btn => btn.setAttribute('aria-selected', 'false')); - tabPanels.forEach(panel => panel.setAttribute('aria-hidden', 'true')); - const defaultTab = tabButtons[0]; - defaultTab.setAttribute('aria-selected', 'true'); - const targetId = defaultTab.getAttribute('aria-controls'); - const defaultPanel = panelsContainer.querySelector(`#${targetId}`); - if (defaultPanel) { - defaultPanel.setAttribute('aria-hidden', 'false'); - } - localStorage.setItem(storageKey, targetId); - } - - // Attach click handlers to each tab button. - tabButtons.forEach(button => { - button.addEventListener('click', () => { - - // Reset selected state and hide panels within this container only - tabButtons.forEach(btn => btn.setAttribute('aria-selected', 'false')); - tabPanels.forEach(panel => panel.setAttribute('aria-hidden', 'true')); - - // Activate the clicked tab and its associated panel - button.setAttribute('aria-selected', 'true'); - const targetId = button.getAttribute('aria-controls'); - const targetPanel = panelsContainer.querySelector(`#${targetId}`); - if (targetPanel) { - targetPanel.setAttribute('aria-hidden', 'false'); - } - localStorage.setItem(storageKey, targetId); - }); - }); - } - - const appAltersStageTabs = document.querySelector('.app-stage.tabs-wrapper'); - if (appAltersStageTabs) { - initTabs(appAltersStageTabs, appAltersStageTabs, 'appAltersStageTabsId'); - } - - document.querySelectorAll('.modal-log.tabs-wrapper').forEach(container => { - if (!container.id) { - container.id = 'modalTabs_' + Math.random().toString(36).substr(2, 9); - } - const storageKey = 'modalActiveTab_' + container.id; - const tabsContainer = container.querySelector('nav.tabs'); - const panelsContainer = container; - initTabs(tabsContainer, panelsContainer, storageKey); - }); - - // ============================================= - // Moment.js - // ============================================= - // For dates - // Note: Relative time formatting is possible, consult documentation - document.querySelectorAll('.date').forEach(el => { - const originalDate = el.textContent.trim(); - const mDate = moment(originalDate); - if (mDate.isValid()) { - el.textContent = mDate.format('YYYY-MM-DD HH:mm:ss'); // MySQL format - } else { - console.warn('Invalid date:', originalDate); - } - }); - - // ============================================= - // Simple-DataTables - // Documentation: fiduswriter.github.io/simple-datatables/documentation - // ============================================= - // Templating - const templateBase = (options, dom) => ` -
- ${options.searchable ? ` -
- pageview - -
` : ""} - ${options.paging && options.perPageSelect ? ` -
- -
` : ""} -
-
-
- ${options.paging ? ` -
` : ""} - -
`; - - // Base configuration - const tableDefault = { - perPageSelect: [10, 20, 50, 100], - prevText: "chevron_left", - nextText: "chevron_right", - searchable: true, - template: templateBase, - labels: { - placeholder: "Search in table...", - searchTitle: "Search in table", - pageTitle: "Page {page}", - perPage: "Entries per page: ", - noRows: "No entries found", - info: "{start}–{end} entries of {rows}", - noResults: "No results match your search query", - }, - columns: [{ - select: 0, - sort: "asc" - }, ], - }; - - // Applying the previous configurations... - document.querySelectorAll('[data-table]').forEach(table => { - let config = { - ...tableDefault - }; - - const tableId = table.getAttribute('data-table') || 'default'; - - // Special configuration for completed-tasks table: sort by "Started at" column (index 2) descending - const defaultSort = tableId === 'completed-tasks' ? { - select: 2, // "Started at" column - sort: "desc" - } : config.columns[0]; - - // Check for saved sort preference in sessionStorage - const sortStorageKey = `datatable-sort-${tableId}`; - const savedSort = sessionStorage.getItem(sortStorageKey); - - if (savedSort) { - try { - const { - column, - direction - } = JSON.parse(savedSort); - config.columns = [{ - select: column, - sort: direction - }]; - } catch (e) { - // If parsing fails, use default sort - config.columns = [defaultSort]; - } - } else { - config.columns = [defaultSort]; - } - - const dataTable = new simpleDatatables.DataTable(table, config); - if (typeof dataTable.on === 'function') { - // Save sort preference to sessionStorage when user changes sort - dataTable.on('datatable.sort', (column, direction) => { - sessionStorage.setItem(sortStorageKey, JSON.stringify({ - column: column, - direction: direction - })); - }); - - dataTable.on('datatable.update', () => { - table.querySelectorAll('.menu').forEach(setupExpandable); - table.querySelectorAll('[data-tooltip]').forEach(trigger => { - if (!trigger._tooltipBound) { - bindTooltip(trigger); - } - }); - table.querySelectorAll('[data-modal="trigger"]').forEach(trigger => { - if (!trigger._modalBound) { - bindModalTrigger(trigger); - } - }); - }); - } - }); - - // ============================================= - // Dropdown menus with Floating UI - // ============================================= - let openMenu = null; - - function setupExpandable(container) { - // Prevent duplicate binding: - if (container._expandableBound) return; - - // Ensure the container has the necessary elements. - const trigger = container.querySelector('.trigger'); - const content = container.querySelector('.content'); - if (!trigger || !content) return; - - // Initially hide the content. - content.style.display = 'none'; - trigger.setAttribute('aria-expanded', 'false'); - content.setAttribute('aria-hidden', 'true'); - - // Function to update the position using Floating UI. - function updatePosition() { - FloatingUIDOM.computePosition(trigger, content, { - placement: 'bottom', - middleware: [ - FloatingUIDOM.offset(4), - FloatingUIDOM.flip(), - FloatingUIDOM.shift({ - padding: 16 - }), - ], - }).then(({ - x, - y - }) => { - Object.assign(content.style, { - position: 'absolute', - left: `${x}px`, - top: `${y}px`, - }); - }); - } - - let cleanupAutoUpdate = null; - - function show() { - content.style.display = 'block'; - updatePosition(); - cleanupAutoUpdate = FloatingUIDOM.autoUpdate(trigger, content, updatePosition); - trigger.setAttribute('aria-expanded', 'true'); - content.setAttribute('aria-hidden', 'false'); - openMenu = container; - } - - function hide() { - content.style.display = 'none'; - if (cleanupAutoUpdate) { - cleanupAutoUpdate(); - cleanupAutoUpdate = null; - } - trigger.setAttribute('aria-expanded', 'false'); - content.setAttribute('aria-hidden', 'true'); - if (openMenu === container) { - openMenu = null; - } - } - - // Attach the hide and show functions - container.hideExpandable = hide; - container.showExpandable = show; - - // Toggle the expandable element when clicking on the trigger - trigger.addEventListener('click', (e) => { - e.stopPropagation(); - if (openMenu && openMenu !== container) { - openMenu.hideExpandable(); - } - const computedDisplay = window.getComputedStyle(content).display; - if (computedDisplay === 'block') { - hide(); - } else { - show(); - } - }); - - // Mark that binding is complete. - container._expandableBound = true; - } - - // Apply this to all elements that need the expandable behavior. - const expandables = document.querySelectorAll('.menu'); - expandables.forEach(setupExpandable); - - // Attach a single document-level listener to hide the open menu if the click is outside the open menu. - document.addEventListener('click', (e) => { - if (openMenu && !openMenu.contains(e.target)) { - openMenu.hideExpandable(); - } - }); - - // ============================================= - // Tooltip functionality with Floating UI - // ============================================= - // Elements with a "data-tooltip" attribute will show a tooltip on hover. - function bindTooltip(trigger) { - // If already bound, skip re‑binding. - if (trigger._tooltipBound) return; - - let tooltipElem = null; - let cleanupAutoUpdate = null; - let tooltipTimeout = null; - - function showTooltip() { - const hasAriaLabel = trigger.hasAttribute('aria-label'); - const tooltipText = hasAriaLabel ? trigger.getAttribute('aria-label') : trigger.getAttribute('data-tooltip'); - let delay = parseInt(trigger.getAttribute('data-tooltip'), 10); - if (isNaN(delay) || delay === 0) { - delay = 200; - } - tooltipTimeout = setTimeout(() => { - tooltipElem = document.createElement('div'); - tooltipElem.className = 'tooltip'; - tooltipElem.innerText = tooltipText; - tooltipElem.style.opacity = '0'; - tooltipElem.style.transition = 'opacity 50ms ease-out'; - document.body.appendChild(tooltipElem); - FloatingUIDOM.computePosition(trigger, tooltipElem, { - placement: 'top', - middleware: [ - FloatingUIDOM.offset(8), - FloatingUIDOM.flip(), - FloatingUIDOM.shift({ - padding: 5 - }), - ], - }).then(({ - x, - y - }) => { - Object.assign(tooltipElem.style, { - position: 'absolute', - left: `${x}px`, - top: `${y}px`, - }); - }); - requestAnimationFrame(() => { - tooltipElem.style.opacity = '1'; - }); - cleanupAutoUpdate = FloatingUIDOM.autoUpdate(trigger, tooltipElem, () => { - FloatingUIDOM.computePosition(trigger, tooltipElem, { - placement: 'top', - middleware: [ - FloatingUIDOM.offset(8), - FloatingUIDOM.flip(), - FloatingUIDOM.shift({ - padding: 5 - }), - ], - }).then(({ - x, - y - }) => { - Object.assign(tooltipElem.style, { - position: 'absolute', - left: `${x}px`, - top: `${y}px`, - }); - }); - }); - }, delay); - } - - function hideTooltip() { - clearTimeout(tooltipTimeout); - if (tooltipElem) { - tooltipElem.style.opacity = '0'; - setTimeout(() => { - if (tooltipElem && tooltipElem.parentNode) { - tooltipElem.parentNode.removeChild(tooltipElem); - tooltipElem = null; - } - }, 100); - } - if (cleanupAutoUpdate) { - cleanupAutoUpdate(); - cleanupAutoUpdate = null; - } - } - trigger.addEventListener('mouseenter', showTooltip); - trigger.addEventListener('mouseleave', hideTooltip); - - // Mark that we've bound tooltip events to this element. - trigger._tooltipBound = true; - } - - // ============================================= - // Soft wrapping for log output - Global behavior - // ============================================= - // Select all triggers and containers based on the data attributes - const softWrapTriggers = document.querySelectorAll('[data-soft-wrap="trigger"]'); - const softWrapContainers = document.querySelectorAll('[data-soft-wrap="container"]'); - - // Function to update all triggers and containers based on the given state - function setGlobalSoftWrapState(state) { - // Update every trigger to match the new state - softWrapTriggers.forEach(trigger => { - trigger.checked = state; - }); - // Add or remove the .soft-wrap class on every container - softWrapContainers.forEach(container => { - if (state) { - container.classList.add('soft-wrap'); - } else { - container.classList.remove('soft-wrap'); - } - }); - } - - // Attach a change listener to each trigger - softWrapTriggers.forEach(trigger => { - trigger.addEventListener('change', function() { - // When one trigger is changed, update all triggers and containers to match its state - setGlobalSoftWrapState(this.checked); - }); - }); - - // ============================================= - // Modal logic using elements - // ============================================= - - function bindModalTrigger(trigger) { - if (trigger._modalBound) return; - trigger.addEventListener('click', (e) => { - e.preventDefault(); - const targetModalId = trigger.getAttribute('data-modal-target'); - if (!targetModalId) return; - const modal = document.getElementById(targetModalId); - if (modal) { - if (typeof modal.showModal === 'function') { - modal.showModal(); - } else { - modal.setAttribute('open', ''); - } - // Update the modal with the task name. - const taskName = trigger.getAttribute('data-task'); - const modalTaskNameSpan = modal.querySelector('#modalTaskName'); - if (modalTaskNameSpan && taskName) { - modalTaskNameSpan.textContent = taskName; - } - const taskNameInput = modal.querySelector('#taskName'); - if (taskNameInput && taskName) { - taskNameInput.value = taskName; - document.getElementById('modalScheduleExecSubmitButton').setAttribute('form', taskName + '-send'); - } - - // Set focus on the first focusable element. - const focusableElements = modal.querySelectorAll( - 'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, [contenteditable], [tabindex]:not([tabindex="-1"])' - ); - if (focusableElements.length) { - focusableElements[0].focus(); - } - - // Add a keydown listener to trap focus and enable Escape closing. - const trapFocus = function(e) { - if (e.key === 'Escape') { - e.preventDefault(); - closeModal(modal); - return; - } - if (e.key === 'Tab') { - const focusable = Array.from(focusableElements).filter( - el => el.offsetParent !== null - ); - if (focusable.length === 0) { - e.preventDefault(); - return; - } - const firstElement = focusable[0]; - const lastElement = focusable[focusable.length - 1]; - if (e.shiftKey) { - if (document.activeElement === firstElement) { - e.preventDefault(); - lastElement.focus(); - } - } else { - if (document.activeElement === lastElement) { - e.preventDefault(); - firstElement.focus(); - } - } - } - }; - modal.addEventListener('keydown', trapFocus); - modal._trapFocusHandler = trapFocus; - } - }); - trigger._modalBound = true; - } - - // 2. Close modals. - // Inside each modal, any element with data-modal-close will close its modal. - const modals = document.querySelectorAll('dialog[aria-modal="true"]'); - modals.forEach(modal => { - // Set up close buttons. - modal.querySelectorAll('[data-modal-close]').forEach(closeButton => { - closeButton.addEventListener('click', (e) => { - e.preventDefault(); - closeModal(modal); - }); - }); - // Close modal when clicking on the backdrop (outside of the content). - modal.addEventListener('click', (e) => { - const modalContent = modal.querySelector('.window'); - if (modalContent && !modalContent.contains(e.target)) { - closeModal(modal); - } - }); - // When the native cancel event fires (e.g. pressing Escape), close the modal. - modal.addEventListener('cancel', (e) => { - e.preventDefault(); - closeModal(modal); - }); - }); - - // Utility function to close modals. - function closeModal(modal) { - // Hide the confirmation dialog - const dialog = document.getElementById('confirmationDialog'); - const dialogContent = document.querySelector('.dialog-content'); - if (dialogContent && dialog) { - dialogContent.classList.remove('pop-up'); - dialogContent.classList.add('pop-down'); - dialog.style.display = 'none'; - } - // Hide the modal - if (typeof modal.close === 'function') { - modal.close(); - } else { - modal.removeAttribute('open'); - } - if (modal._trapFocusHandler) { - modal.removeEventListener('keydown', modal._trapFocusHandler); - modal._trapFocusHandler = null; - } - } - - // ============================================= - // Binding - // ============================================= - // Initial binding of tooltips on page load - document.querySelectorAll('[data-tooltip]').forEach(trigger => { - bindTooltip(trigger); - }); - // Initial binding of expandable menus on page load - document.querySelectorAll('[data-modal="trigger"]').forEach(trigger => { - bindModalTrigger(trigger); - }); - - - -}); - -document.fonts.ready.then(() => { - document.documentElement.classList.add('fonts-loaded'); -}); diff --git a/static/js/chain-builder.js b/static/js/chain-builder.js deleted file mode 100644 index 17322868da..0000000000 --- a/static/js/chain-builder.js +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Copyright (C) 2026 Percona LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -$(document).ready(function() { - window.ChainBuilder = function($container, options) { - this.$container = $container; - this.formId = options.formId || $container.data('form-id'); - this.taskName = options.taskName || $container.data('task-name'); - this.inputName = options.inputName || $container.data('input-name') || 'chain_task_names'; - this.chain = []; - this.chainOnFailure = $container.data('chain-on-failure') === true || $container.data('chain-on-failure') === 'true'; - - this.$sequence = $container.find('.chain-sequence'); - this.$select = $container.find('.chain-task-select'); - this.$chainOptions = $container.find('.chain-options'); - this.$chainOnFailureCheckbox = $container.find('.chain-on-failure-checkbox'); - - var initialChain = options.initialChain || $container.data('initial-chain'); - if (initialChain) { - if (typeof initialChain === 'string') { - try { - initialChain = JSON.parse(initialChain); - } catch (e) { - initialChain = []; - } - } - for (var i = 0; i < initialChain.length; i++) { - this.chain.push(initialChain[i]); - } - } - - this._bindEvents(); - this._render(); - }; - - ChainBuilder.prototype._wouldCreateCycle = function(name) { - if (name === this.taskName) { - return true; - } - for (var i = 0; i < this.chain.length; i++) { - if (this.chain[i] === name) { - return true; - } - } - return false; - }; - - ChainBuilder.prototype.addTask = function(name) { - if (!name || this._wouldCreateCycle(name)) { - return; - } - this.chain.push(name); - this._render(); - }; - - ChainBuilder.prototype.removeTask = function(name) { - this.chain = this.chain.filter(function(n) { - return n !== name; - }); - this._render(); - }; - - ChainBuilder.prototype._render = function() { - this.$sequence.empty(); - for (var i = 0; i < this.chain.length; i++) { - var name = this.chain[i]; - var $item = $('
').attr('data-task-name', name); - var $chip = $(''); - $chip.append($('').text(name)); - var $removeBtn = $(' - - - `; - }).join(''); - - const html = ` -
- -
-
- -
-
- - - - - ${rows} -
FileSizeDownload
-
-
- -
-
-
-
- `; - const $anchor = $(`dialog#modal-${taskId}`).parent().length ? - $(`dialog#modal-${taskId}`).parent() : - $('body'); - $anchor.append(html); - $modal = $(`#modal-files-${taskId}`); - } - - $modal.find('tbody tr').each(function() { - updateFileRowDisplay($(this)); - }); - - return $modal; - } - - function openDialog(id) { - const el = document.getElementById(id); - if (el && typeof el.showModal === 'function') el.showModal(); - } - - function closeDialog(el) { - if (el && typeof el.close === 'function') el.close(); - } - - $('.modal-files').each(function() { - $(this).find('tbody tr').each(function() { - updateFileRowDisplay($(this)); - }); - }); - - $(document).on('click', '.download-file-button', function() { - const $btn = $(this); - - if ($btn.data('loading') === true) return; - - const $row = $btn.closest('tr'); - const taskId = $row.data('task-id'); - const filename = String($row.data('file-name') || ''); - const isDir = String($row.data('file-is-dir')).toLowerCase() === 'true'; - if (!taskId || !filename) return; - - $btn.data('loading', true) - .attr('aria-disabled', 'true') - .prop('disabled', true) - .addClass('loading') - .html(` - - Preparing download - `); - - $row.attr('aria-busy', 'true'); - - const url = `/files/${encodeURIComponent(taskId)}/download?path=${encodeURIComponent(filename)}`; - const a = document.createElement('a'); - a.href = url; - const baseName = filename.split('/').pop(); - a.download = isDir ? `${baseName}.tar.gz` : baseName; - document.body.appendChild(a); - a.click(); - a.remove(); - - // Reset button after download has been triggered (no reliable "download started" event) - const ariaLabel = $btn.attr('aria-label') || `Download ${filename}`; - setTimeout(function() { - $btn.data('loading', false) - .attr('aria-disabled', 'false') - .prop('disabled', false) - .removeClass('loading') - .attr('aria-label', ariaLabel) - .html('file_download'); - $row.removeAttr('aria-busy'); - }, 1500); - }); - - $(document).on('click', '.view-files-button', function() { - const target = $(this).attr('data-modal-target'); - if (target) openDialog(target); - }); - - - - const lastOffsets = {}; - const loadedCompletedTasks = new Set(); - const executionEventsFetched = new Set(); - const executionEventsStreams = {}; - const executionEventsCache = {}; - - function executionEventKey(ev) { - return JSON.stringify({ - timestamp: ev && ev.timestamp, - type: ev && ev.type, - description: ev && ev.description, - step: ev && ev.step, - }); - } - - function appendExecutionEvent(taskId, eventObj) { - executionEventsCache[taskId] = executionEventsCache[taskId] || []; - const exists = executionEventsCache[taskId].some(function(item) { - return executionEventKey(item) === executionEventKey(eventObj); - }); - if (!exists) { - executionEventsCache[taskId].push(eventObj); - } - } - - function closeExecutionEventsStream(taskId) { - const src = executionEventsStreams[taskId]; - if (src) { - src.close(); - delete executionEventsStreams[taskId]; - } - } - - function getSelectedLogStepName($logConsole) { - const $tab = $logConsole.find('.log-footer .log-step-tab.selected'); - if ($tab.length) { - return String($tab.attr('data-step-name') || $tab.data('step-name') || ''); - } - const $first = $logConsole.find('.log-footer .log-step-tab').first(); - return $first.length ? - String($first.attr('data-step-name') || $first.data('step-name') || '') : - ''; - } - - /** Show the execution-events panel for the bottom step tab that is selected (or first panel). */ - function syncExecutionEventsStepVisibility($logConsole) { - const $view = $logConsole.find('.log-events-view'); - if ($view.length === 0) return; - const stepName = getSelectedLogStepName($logConsole); - const $panels = $view.find('.log-events-step-panel'); - const $missing = $view.find('.log-events-step-missing'); - $panels.hide(); - $missing.hide(); - if ($panels.length === 0) { - return; - } - if (!stepName) { - $panels.first().show(); - return; - } - let found = false; - $panels.each(function() { - const $p = $(this); - if ($p.attr('data-step-name') === stepName) { - $p.show(); - found = true; - return false; - } - }); - if (!found) { - $missing.show(); - } - } - - function buildExecutionEventsUi($logConsole, taskId, events) { - const $stderrTab = $logConsole.find('[role="log-tab"][aria-controls*="stderr"]'); - if ($stderrTab.length === 0) return; - - let $eventsTab = $logConsole.find('#tab-events-' + taskId); - if ($eventsTab.length === 0) { - $eventsTab = $( - '' - ); - $eventsTab.attr('id', 'tab-events-' + taskId); - $eventsTab.attr('aria-controls', 'log-events-' + taskId); - $stderrTab.after($eventsTab); - } - - let $panel = $logConsole.find('#log-events-' + taskId); - if ($panel.length === 0) { - $panel = $('
'); - $panel.attr('id', 'log-events-' + taskId); - $panel.attr('aria-label', 'Execution events'); - $panel.attr('role', 'region'); - $panel.append('
'); - $panel.append( - $('
').text( - 'No execution events for this step.' - ) - ); - $logConsole.find('.log-content').append($panel); - $panel.hide(); - } - - const $wrap = $panel.find('.log-events-step-panels'); - $wrap.empty(); - - const stepOrder = []; - const byStep = {}; - (Array.isArray(events) ? events : []).forEach(function(ev) { - const s = ev.step != null && String(ev.step) !== '' ? String(ev.step) : ''; - const key = s || '_'; - if (!byStep[key]) { - byStep[key] = []; - stepOrder.push(key); - } - byStep[key].push(ev); - }); - - stepOrder.forEach(function(key) { - const list = byStep[key]; - const dataStep = key === '_' ? '' : key; - const $section = $('
'); - $section.attr('data-step-name', dataStep); - const $ol = $('
    '); - list.forEach(function(ev) { - const ts = ev.timestamp != null ? String(ev.timestamp) : ''; - const st = ev.step != null && String(ev.step) !== '' ? String(ev.step) : ''; - const typ = ev.type != null ? String(ev.type) : ''; - const desc = ev.description != null ? String(ev.description) : ''; - const body = st ? typ + '[' + st + '] ' + desc : typ + ' ' + desc; - const $li = $('
  1. '); - $li.append( - $('') - .attr('datetime', ts) - .text(ts) - ); - $li.append($('
    ').text(body)); - $ol.append($li); - }); - $section.append($ol); - $wrap.append($section); - }); - - if (stepOrder.length === 0) { - $panel.find('.log-events-step-missing').text('No execution events yet.'); - } else { - $panel.find('.log-events-step-missing').text('No execution events for this step.'); - } - - syncExecutionEventsStepVisibility($logConsole); - } - - function fetchExecutionEventsIfNeeded($logConsole, taskId) { - const status = String($logConsole.data('task-status') || '').toLowerCase(); - const isRunning = status === 'running'; - if (isRunning) { - if (executionEventsStreams[taskId]) return; - executionEventsCache[taskId] = executionEventsCache[taskId] || []; - buildExecutionEventsUi($logConsole, taskId, executionEventsCache[taskId]); - const src = new EventSource( - `/stream-logs/${encodeURIComponent(taskId)}/execution-events` - ); - executionEventsStreams[taskId] = src; - const $logDialog = $logConsole.closest('dialog'); - if ($logDialog.length) { - $logDialog[0].addEventListener( - 'close', - function() { - closeExecutionEventsStream(taskId); - }, { - once: true - } - ); - } - src.onmessage = function(event) { - let payload; - try { - payload = JSON.parse(event.data); - } catch (e) { - console.warn('Invalid execution event payload:', event.data); - return; - } - appendExecutionEvent(taskId, payload); - buildExecutionEventsUi($logConsole, taskId, executionEventsCache[taskId]); - if (!$logConsole.parent().prop('open')) { - closeExecutionEventsStream(taskId); - } - }; - src.addEventListener('finish', function() { - closeExecutionEventsStream(taskId); - }); - src.addEventListener('sep-error', function() { - closeExecutionEventsStream(taskId); - }); - src.onerror = function() { - if (!$logConsole.parent().prop('open')) { - closeExecutionEventsStream(taskId); - } - }; - return; - } - if (executionEventsFetched.has(taskId)) return; - fetch(`/execution-events/${encodeURIComponent(taskId)}`, { - credentials: 'include', - }) - .then(function(res) { - return res.ok ? res.json() : []; - }) - .then(function(events) { - executionEventsCache[taskId] = Array.isArray(events) ? events : []; - buildExecutionEventsUi($logConsole, taskId, executionEventsCache[taskId]); - executionEventsFetched.add(taskId); - }) - .catch(function() { - executionEventsFetched.add(taskId); - }); - } - - window.clearLoadedTasks = function() { - loadedCompletedTasks.clear(); - executionEventsFetched.clear(); - Object.keys(executionEventsStreams).forEach(closeExecutionEventsStream); - }; - - $('.view-logs-button').click(function() { - const taskId = $(this).data('task-id'); - const $logConsole = $(`.log-console.streaming-console[data-task-id=${taskId}]`); - - fetchExecutionEventsIfNeeded($logConsole, taskId); - - if (loadedCompletedTasks.has(taskId)) { - if ($logConsole.find('.log-content').children().length === 0) { - $logConsole.find('.log-content').html('
    Logs already loaded
    '); - } - return; - } - - if ($logConsole.find('.log-step-content').length > 0) { - loadedCompletedTasks.add(taskId); - return; - } - - lastOffsets[taskId] = lastOffsets[taskId] || {}; - - const offsetQueryParams = Object.entries(lastOffsets[taskId]).map( - ([key, offset]) => `${encodeURIComponent(key)}_offset=${encodeURIComponent(offset)}` - ); - const offsetQueryString = offsetQueryParams.join('&'); - - const eventSource = new EventSource(`/stream-logs/${taskId}?${offsetQueryString}`); - eventSource.onmessage = function(event) { - let data; - try { - data = JSON.parse(event.data); - } catch (e) { - console.warn("Non-JSON log chunk, ignoring:", event.data); - return; - } - console.log(`Received log message for task ${taskId}:`, data); - - const { - msg, - step, - type, - offset - } = data; - - if (!msg || !step || !type || offset === undefined) { - console.warn("Ignoring malformed log chunk, missing fields:", data); - return; - } - - const offsetKey = `${step}_${type}`; - if (offset <= (lastOffsets[taskId][offsetKey] || 0)) return; - lastOffsets[taskId][offsetKey] = offset; - - - - if ($logConsole.find('.log-step-content[data-step-name="' + step + '"]').length === 0) { - const stepTab = $(''); - $logConsole.find('.log-footer .log-tabs').append(stepTab); - - if ($logConsole.find('.log-step-tab').length === 1) stepTab.addClass('selected'); - - const stepContent = $('
    '); - if ($logConsole.find('.log-step-content').length === 0) stepContent.show(); - else stepContent.hide(); - - const stdoutPre = $(''); - const stderrPre = $(''); - - const $checkbox = $logConsole.find('.word-wrap-checkbox'); - if ($checkbox.is(':checked')) { - stdoutPre.addClass('soft-wrap'); - stderrPre.addClass('soft-wrap'); - } - - const selectedTab = $logConsole.find('[role="log-tab"][aria-selected="true"]'); - const selectedLogType = selectedTab.attr('aria-controls').includes('stdout') ? 'stdout' : 'stderr'; - if (selectedLogType === 'stdout') stdoutPre.show(); - else stderrPre.show(); - - stepContent.append(stdoutPre); - stepContent.append(stderrPre); - $logConsole.find('.log-content').append(stepContent); - - const topControlsAfterStep = - $logConsole.find('[role="log-tab"][aria-selected="true"]').attr('aria-controls') || ''; - if (topControlsAfterStep.indexOf('log-events') === 0) { - syncExecutionEventsStepVisibility($logConsole); - } - } - - const $pre = $logConsole.find('.log-step-content[data-step-name="' + step + '"] .log-output[data-log-type="' + type + '"]'); - $pre.append(document.createTextNode(msg)); - - const $logContent = $logConsole.find('.log-content'); - $logContent.scrollTop($logContent[0].scrollHeight); - - const selectedTabType = $logConsole.find('[role="log-tab"][aria-selected="true"]').attr('aria-controls').includes('stdout') ? 'stdout' : 'stderr'; - if (type !== selectedTabType) { - $logConsole.find('[role="log-tab"][aria-controls="log-' + type + '-' + taskId + '"]').addClass('tab-notification'); - } - - const selectedStep = $logConsole.find('.log-step-tab.selected').data('step-name'); - if (step !== selectedStep) { - $logConsole.find('.log-step-tab[data-step-name="' + step + '"]').addClass('tab-notification'); - } - - console.log(`Log console is open: ${$logConsole.parent().prop('open')}`); - if (!$logConsole.parent().prop('open')) { - eventSource.close(); - } - }; - - eventSource.addEventListener('finish', function(event) { - eventSource.close(); - const finishData = JSON.parse(event.data); - - if (lastOffsets[taskId] === 0) { - setTimeout(() => { - window.location.reload() - }, 5000); - } else { - console.log(`Log stream for ${taskId} finished`); - - const statusClass = finishData.status; - if (finishData.status === 'success') { - var icon = 'check'; - var label = 'Done'; - } else if (finishData.status === 'stopped') { - var icon = 'cancel'; - var label = 'Stopped'; - } else if (finishData.status === 'lost') { - var icon = 'question_mark'; - var label = 'Lost'; - } else { - var icon = 'report'; - var label = 'Failed'; - } - - const statusEl = $(` -
    -
    - ${icon} - ${label} -
    -
    - `); - - (async () => { - try { - const res = await fetch(`/files/${encodeURIComponent(taskId)}`, { - credentials: 'include' - }); - if (!res.ok) return; - const filesMap = await res.json(); - if (!filesMap || Object.keys(filesMap).length === 0) return; - - const taskName = $logConsole.find('h3.title').text().replace(/^Logs for\s+/, ''); - ensureFilesModal(taskId, filesMap, taskName); - - const existingBtn = $logConsole.find('.log-header .view-files-from-log-button'); - if (existingBtn.length === 0) { - const btn = $(` - - `); - $logConsole.find('.log-header .tabs').append(btn); - } - - $logConsole.on('click', '.view-files-from-log-button', function() { - const filesModalId = $(this).attr('data-modal-target'); - closeDialog($logConsole.closest('dialog')[0]); - openDialog(filesModalId); - }); - } catch (err) { - console.warn('Could not load files list after finish:', err); - } - })(); - - $logConsole.find('.log-footer .log-tabs').append(statusEl); - $logConsole.addClass(finishData.status); - delete lastOffsets[taskId]; - - loadedCompletedTasks.add(taskId); - } - }); - - eventSource.addEventListener('sep-error', function(event) { - let payload; - try { - payload = JSON.parse(event.data); - } catch { - payload = { - detail: String(event.data || 'Unknown error') - }; - } - console.error(`SSE server error for task ${taskId}:`, payload); - - const code = payload.code; - const detail = payload.detail; - - const $logContent = $logConsole.find('.log-content'); - if (code === 410 && detail && typeof detail === 'object') { - $logContent.prepend(buildExecutorGoneMessage(detail)); - } else { - const text = - typeof detail === 'string' ? - detail : - detail != null ? - JSON.stringify(detail, null, 2) : - 'Unknown stream error'; - $logContent.prepend( - $('').append( - $('
    ').text(text)
    -                    )
    -                );
    -            }
    -            $logContent.scrollTop(0);
    -
    -            const isGone = code === 410;
    -            const label = isGone ? 'Not in executor' : 'Stream error';
    -            const icon = isGone ? 'cloud_off' : 'chat_error';
    -
    -            const statusEl = $(`
    -                    
    -
    - ${icon} - ${label} -
    -
    - `); - - $logConsole.find('.log-footer .log-tabs').append(statusEl); - $logConsole.addClass('failed'); - - eventSource.close(); - }); - - eventSource.onerror = function(e) { - console.error(`Error receiving SSE for task ${taskId}:`, e); - if (lastOffsets[taskId] === 0) { - eventSource.close(); - setTimeout(() => { - window.location.reload() - }, 5000); - } - }; - }); - - $('.logs-button').click(function() { - const logId = $(this).data('log-id'); - const logRow = $('tr.log-row[data-log-id="' + logId + '"]'); - logRow.toggle(); - const button = $(this); - if (logRow.is(':visible')) { - button.text('visibility_off'); - } else { - button.text('visibility'); - } - }); - - $(document).on('change', '.word-wrap-checkbox', function() { - const $this = $(this); - const $logConsole = $this.closest('.log-console'); - if ($this.is(':checked')) { - $logConsole.find('.log-output').addClass('soft-wrap'); - } else { - $logConsole.find('.log-output').removeClass('soft-wrap'); - } - }); - // Initialize state for checkboxes that exist at page load - $('.word-wrap-checkbox').trigger("change"); - - $('.toggle-label').click(function(e) { - $(this).prev(".switch").click(); - }); - $('.modal-log').on('click', 'button[role="log-tab"]', function(e) { - e.preventDefault(); - const $this = $(this); - const $modal = $this.closest('.modal-log'); - const $logConsole = $modal.find('.log-console'); - const $logContent = $logConsole.find('.log-content'); - const controls = $this.attr('aria-controls') || ''; - - $this.attr('aria-selected', 'true').siblings('[role="log-tab"]').attr('aria-selected', 'false'); - - if (controls.indexOf('log-events') === 0) { - $logContent.find('.log-step-content').hide(); - $logContent.find('.log-events-view').show(); - syncExecutionEventsStepVisibility($logConsole); - return; - } - - $logContent.find('.log-events-view').hide(); - - const $selectedStepBtn = $logConsole.find('.log-footer .log-step-tab.selected'); - let $stepPane = $(); - if ($selectedStepBtn.length) { - $stepPane = $logContent.find( - '.log-step-content[data-step-name="' + $selectedStepBtn.data('step-name') + '"]' - ); - } - if ($stepPane.length === 0) { - $stepPane = $logContent.find('.log-step-content').first(); - } - $logContent.find('.log-step-content').hide(); - if ($stepPane.length) { - $stepPane.show(); - } - - const logType = controls.includes('stdout') ? 'stdout' : 'stderr'; - $modal.find('.log-output').hide(); - if ($stepPane.length) { - $stepPane.find('.log-output[data-log-type="' + logType + '"]').show(); - } - }); - - $('.modal-log').on('click', '.log-step-tab', function(e) { - e.preventDefault(); - const $this = $(this); - const $modal = $this.closest('.modal-log'); - const $logConsole = $modal.find('.log-console'); - const $logContent = $logConsole.find('.log-content'); - const stepName = $this.data('step-name'); - - $this.addClass('selected').siblings('.log-step-tab').removeClass('selected'); - - const topControls = $modal.find('[role="log-tab"][aria-selected="true"]').attr('aria-controls') || ''; - if (topControls.indexOf('log-events') === 0) { - syncExecutionEventsStepVisibility($logConsole); - return; - } - - $logContent.find('.log-events-view').hide(); - $modal.find('.log-step-content').hide(); - const $selectedStep = $modal.find('.log-step-content[data-step-name="' + stepName + '"]'); - $selectedStep.show(); - - const selectedTab = $modal.find('[role="log-tab"][aria-selected="true"]'); - const logType = selectedTab.attr('aria-controls').includes('stdout') ? 'stdout' : 'stderr'; - - $selectedStep.find('.log-output').hide(); - $selectedStep.find('.log-output[data-log-type="' + logType + '"]').show(); - }); - -}); diff --git a/static/js/messages.js b/static/js/messages.js deleted file mode 100644 index ba5f3f3636..0000000000 --- a/static/js/messages.js +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Copyright (C) 2026 Percona LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -$(function() { - $('.close-button').click(function() { - const $msg = $(this).closest('.message'); - $msg.off(); - $msg.fadeOut(500, function() { - $msg.remove(); - }); - }); - - const timers = { - 'info': 10000, - 'success': 10000, - 'warning': 20000, - 'error': 30000 - }; - - const messages = $('#messages .message:not(.sticky)'); - messages.each(function() { - const $msg = $(this); - const level = $msg.data('level') || 'info'; - $msg.data('timeoutId', setTimeout(function() { - $msg.fadeOut(1000, function() { - $(this).remove(); - }); - }, timers[level])); - }); - - messages.hover(function() { - const $msg = $(this); - $msg.stop().animate({ - opacity: '100' - }); - clearTimeout($msg.data('timeoutId')); - }, function() { - const $msg = $(this); - const level = $msg.data('level') || 'info'; - $msg.data('timeoutId', setTimeout(function() { - $msg.fadeOut(1000, function() { - $(this).remove(); - }); - }, timers[level])); - }); -}); diff --git a/static/js/relativeTime.js b/static/js/relativeTime.js deleted file mode 100644 index 0cb405c785..0000000000 --- a/static/js/relativeTime.js +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Copyright (C) 2026 Percona LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -function humanizeRelativeTime(date) { - const now = Date.now(); - const diff = date.getTime() - now; - const seconds = Math.abs(diff / 1000); - - const minute = 60; - const hour = 60 * minute; - const day = 24 * hour; - - let relativeTime = ""; - - if (seconds <= 44) { - relativeTime = "a few seconds"; - } else if (seconds <= 89) { - relativeTime = "a minute"; - } else if (seconds <= 44 * minute) { - const minutes = Math.round(seconds / minute); - relativeTime = `${minutes} minutes`; - } else if (seconds <= 89 * minute) { - relativeTime = "an hour"; - } else if (seconds <= 21 * hour) { - const hours = Math.round(seconds / hour); - relativeTime = `${hours} hours`; - } else if (seconds <= 35 * hour) { - relativeTime = "a day"; - } else if (seconds <= 25 * day) { - const days = Math.round(seconds / day); - relativeTime = `${days} days`; - } else if (seconds <= 45 * day) { - relativeTime = "a month"; - } else if (seconds <= 319 * day) { - let months = Math.round(seconds / (30 * day)); - months = Math.min(Math.max(months, 2), 10); - relativeTime = `${months} months`; - } else if (seconds <= 547 * day) { - relativeTime = "a year"; - } else { - const years = Math.round(seconds / (365 * day)); - relativeTime = `${years} years`; - } - - return diff > 0 ? `in ${relativeTime}` : `${relativeTime} ago`; -} - -$(document).ready(function() { - $('.relativeTime').hover(function() { - const $dateElem = $(this); - const relativeDateText = $dateElem.data("relative-date-value") || $dateElem.text(); - const relativeDate = new Date(relativeDateText.trim()); - const titlePrefix = $dateElem.data("relative-date-title-prefix") || "" - $dateElem.attr('title', titlePrefix + humanizeRelativeTime(relativeDate)); - }); -}); diff --git a/static/js/saved.js b/static/js/saved.js deleted file mode 100644 index fc73323ce4..0000000000 --- a/static/js/saved.js +++ /dev/null @@ -1,187 +0,0 @@ -/** - * Copyright (C) 2026 Percona LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -document.addEventListener('DOMContentLoaded', () => { - // Scheduling behavior for saved tasks - // ============================== - // Select all scheduling trigger buttons and their targets - const scheduleButtons = document.querySelectorAll('.triggerScheduleExec[data-modal-target="modalScheduleExec"]'); - scheduleButtons.forEach(scheduleButton => { - scheduleButton.addEventListener('click', function(e) { - const taskName = this.getAttribute('data-task'); - if (!taskName) return; - // Find form and submit button - const sendForm = document.getElementById(taskName + '-send'); - if (!sendForm) return; - const sendButton = sendForm.querySelector('button'); - // Locate the info-delete container - const parentTD = this.closest('td'); - let infoDeleteContainer = null; - if (parentTD) { - infoDeleteContainer = parentTD.querySelector('.info-delete-container'); - } - - // Toggle scheduling state on this button. - if (this.classList.contains('toggled-on')) { - this.classList.remove('toggled-on'); - this.setAttribute('aria-expanded', 'false'); - sendForm.classList.remove('expanded'); - sendForm.setAttribute('data-confirm-message', `Are you sure you want to execute task "${taskName}" now?`); - if (sendButton) { - sendButton.setAttribute('title', 'Execute'); - } - } else { - this.classList.add('toggled-on'); - this.setAttribute('aria-expanded', 'true'); - sendForm.classList.add('expanded'); - sendForm.setAttribute('data-confirm-message', `Are you sure you want to schedule task "${taskName}"?`); - if (sendButton) { - sendButton.setAttribute('title', 'Schedule'); - } - } - }); - }); - // Scheduling modal – update hidden ETA value - // ============================== - const scheduleModal = document.getElementById('modalScheduleExec'); - if (scheduleModal) { - const dateTimeInput = scheduleModal.querySelector('#dateTimeInput'); - const modalEtaInput = scheduleModal.querySelector('#modalEtaValue'); - const submitScheduleButton = scheduleModal.querySelector('#modalScheduleExecSubmitButton'); - - if (dateTimeInput && modalEtaInput) { - ['focus', 'blur', 'change'].forEach(function(eventType) { - dateTimeInput.addEventListener(eventType, function() { - submitScheduleButton.disabled = true; - const etaValue = this.value; - if (etaValue) { - const isoStr = new Date(etaValue).toISOString(); - modalEtaInput.disabled = false; - modalEtaInput.value = isoStr; - // Also update the send form's hidden ETA value: - // the modal should have a hidden input #taskName containing the taskName. - const taskNameInput = scheduleModal.querySelector('#taskName'); - if (taskNameInput && taskNameInput.value) { - const sendForm = document.getElementById(taskNameInput.value + '-send'); - if (sendForm) { - const etaValueInput = sendForm.querySelector('.eta-value'); - if (etaValueInput) { - etaValueInput.disabled = false; - etaValueInput.value = isoStr; - } - } - } - console.log("Scheduled ETA (ISO):", isoStr); - submitScheduleButton.disabled = false; - } else { - modalEtaInput.disabled = true; - } - }); - }) - } - } - - document.addEventListener('DOMContentLoaded', function() { - const scheduleForm = document.getElementById('schedule-task-form'); - const dialog = document.getElementById('modalScheduleExec'); - const etaInput = document.getElementById('dateTimeInput'); - const etaHidden = document.getElementById('modalEtaValue'); - - scheduleForm.addEventListener('submit', function(event) { - // First: close the modal - dialog.close(); - - // Small delay to let modal hide before confirm appears - setTimeout(() => { - const confirmMessage = scheduleForm.dataset.confirmMessage || 'Are you sure?'; - const confirmed = confirm(confirmMessage); - - if (confirmed) { - // Enable and set the hidden eta value - etaHidden.value = etaInput.value; - etaHidden.disabled = false; - - // Manually submit the form (since we previously prevented it) - scheduleForm.submit(); - } else { - // If not confirmed, do nothing - } - }, 10); // Slight delay to allow dialog to disappear - - // Stop the default form submission for now - event.preventDefault(); - }); - }); - -}); - -// $(document).ready(function() { -// $('.schedule-button').on('click', function() { -// const $scheduleButton = $(this); -// const taskName = $scheduleButton.data('task'); -// const $sendForm = $('#' + taskName + '-send'); -// const $etaInput = $sendForm.find('.eta-input'); -// const $sendButton = $sendForm.find('.send-button'); -// const $infoDeleteContainer = $scheduleButton.siblings('.info-delete-container'); - -// if ($scheduleButton.hasClass('toggled-on')) { -// $scheduleButton.removeClass('toggled-on'); -// $scheduleButton.attr('aria-expanded', 'false'); - -// $etaInput.attr('disabled', true); -// $etaInput.attr('required', false); -// $sendForm.removeClass('expanded'); -// $sendForm.attr('data-confirm-message', `Are you sure you want to execute task "${taskName}" now?`); -// $sendButton.attr('title', 'Execute'); - - -// $infoDeleteContainer.show(); -// } else { -// $scheduleButton.addClass('toggled-on'); -// $scheduleButton.attr('aria-expanded', 'true'); - -// $sendForm.addClass('expanded'); -// $etaInput.attr('disabled', false); -// $etaInput.attr('required', true); -// $sendForm.attr('data-confirm-message', `Are you sure you want to schedule task "${taskName}"?`); -// $sendButton.attr('title', 'Schedule'); - -// $infoDeleteContainer.hide(); - -// setTimeout(function() { -// $etaInput.focus(); -// }, 500); -// } -// }); - -// $('.eta-input').change(function() { -// const $sendForm = $(this).parent().parent(); -// const $etaInput = $(this); -// const etaValue = $etaInput.val(); -// const $scheduleButton = $sendForm.find('.schedule-button'); -// const $etaInputValue = $sendForm.find('.eta-value'); -// if ($scheduleButton.hasClass('toggled-on') && etaValue) { -// $etaInputValue.attr('disabled', false); -// const etaDate = new Date(etaValue); -// $etaInputValue.val(etaDate.toISOString()); -// console.log(etaDate.toISOString()); -// } else { -// $etaInputValue.attr('disabled', true); -// } -// return true; -// }); -// }); diff --git a/static/js/scheduled.js b/static/js/scheduled.js deleted file mode 100644 index 25667da46f..0000000000 --- a/static/js/scheduled.js +++ /dev/null @@ -1,498 +0,0 @@ -/** - * Copyright (C) 2026 Percona LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -$(document).ready(function() { - const cronstrue = window.cronstrue; - const cronValidation = window.cronValidation || null; - - // ======================================== - // Helper Functions - // ======================================== - - function humanizeCronExpression(cronExpression) { - try { - let humanized = cronstrue.toString(cronExpression); - return humanized.charAt(0).toLowerCase() + humanized.slice(1); - } catch (e) { - console.error('Invalid cron expression:', cronExpression); - return null; - } - } - - function getHumanizedCronIfValid(cronExpression) { - if (!cronValidation || !cronValidation.isCronExpressionValid(cronExpression)) { - return null; - } - return humanizeCronExpression(cronExpression.trim()); - } - - function updateCronDescription($input) { - const cronExpression = $input.val(); - const $cronDescription = $input.closest('.cron-inputs').find('.cron-description'); - - if (cronExpression) { - const humanized = getHumanizedCronIfValid(cronExpression); - if (humanized) { - $cronDescription.text(humanized); - $input.removeClass('invalid'); - } else { - $cronDescription.text('Invalid cron expression'); - $input.addClass('invalid'); - } - } else { - $cronDescription.text(''); - } - } - - function validateScheduleInputs($row, isCronMode) { - if (isCronMode) { - const $cronInput = $row.find('input[name="cron_expression"]'); - const cronExpression = $cronInput.val(); - - if (!cronExpression) { - alert('Please provide a cron expression.'); - $cronInput.addClass('invalid'); - return false; - } - - if (!getHumanizedCronIfValid(cronExpression)) { - alert('Invalid cron expression.'); - $cronInput.addClass('invalid'); - return false; - } - - $cronInput.removeClass('invalid'); - } else { - const $intervalInput = $row.find('input[name="interval_every"]'); - if ($intervalInput.val() === '') { - alert('Please provide an interval every value.'); - $intervalInput.addClass('invalid'); - return false; - } - $intervalInput.removeClass('invalid'); - } - - return true; - } - - function updateDateTimeInput($row) { - const $dateInput = $row.find('input[type="datetime-local"]'); - const dateValue = $dateInput.val(); - - if (dateValue) { - const $dateInputValue = $row.find('.date-value'); - const awareDate = new Date(dateValue); - $dateInputValue.val(awareDate.toISOString()); - } - } - - function togglePeriodMode($link) { - const taskId = $link.data('task-id'); - let $intervalDiv, $cronDiv, $row; - - if (taskId) { - const $editRow = $(`.edit-periodic-task-row[data-task-id="${taskId}"]`); - $row = $editRow; - $intervalDiv = $editRow.find('.interval-inputs'); - $cronDiv = $editRow.find('.cron-inputs'); - } else { - $row = $('.new-periodic-task-row'); - $intervalDiv = $row.find('.interval-inputs'); - $cronDiv = $row.find('.cron-inputs'); - } - - $intervalDiv.toggleClass('hidden'); - $cronDiv.toggleClass('hidden'); - - const cronIsActive = $intervalDiv.hasClass('hidden'); - $intervalDiv.children().attr('required', !cronIsActive).attr('disabled', cronIsActive); - $cronDiv.find('div').first().children().attr('required', cronIsActive).attr('disabled', !cronIsActive); - $link.text(cronIsActive ? 'change to interval mode' : 'change to cron mode'); - - const $startTimeContainer = $row.find('.start-time-container'); - if (cronIsActive) { - $startTimeContainer.addClass('hidden'); - $startTimeContainer.find('input[type="datetime-local"]').val(''); - $startTimeContainer.find('.date-value').val(''); - } else { - $startTimeContainer.removeClass('hidden'); - } - } - - function initializeSelect2($select, browserTimezone = null) { - $select.select2({ - placeholder: 'Select a timezone', - width: '130px', - }); - - if (browserTimezone && availableTimezones.includes(browserTimezone)) { - $select.val(browserTimezone).trigger('change'); - } else { - $select.val('UTC').trigger('change'); - } - } - - // ======================================== - // Initialization - // ======================================== - - // Humanize cron expressions in table - $('.period-cell.period-crontab').each(function() { - const cronExpression = $(this).text().trim(); - const timezone = $(this).data('timezone'); - - if (cronExpression) { - const humanized = humanizeCronExpression(cronExpression); - if (humanized) { - const displayText = timezone.length > 0 ? `${humanized} (${timezone})` : humanized; - $(this).attr('title', displayText); - } - } - }); - - // Set delete confirmation messages - $('#scheduled-tasks-table .delete-form').each(function() { - const taskName = $(this).closest('tr').find('td:first').text().trim(); - const periodCell = $(this).closest('tr').find('.period-cell'); - const periodDescription = periodCell.attr('title') || periodCell.text().trim(); - $(this).attr('data-confirm-message', - `Are you sure you want to delete the periodic task for "${taskName}" (${periodDescription})?`); - }); - - // ======================================== - // Event Handlers - Enable/Disable Toggles - // ======================================== - - $('.new-periodic-task-enable-checkbox, .edit-periodic-task-enable-checkbox').change(function() { - const checkbox = $(this); - const enabled = checkbox.is(':checked'); - checkbox.parent('label.switch').siblings('input[name="enabled"]').val(enabled ? 'true' : 'false'); - }); - - $('.enable-checkbox').change(function(e) { - e.preventDefault(); - const checkbox = $(this); - const form = checkbox.closest('.enable-form'); - const taskName = checkbox.closest('tr').find('td:first').text().trim(); - const enabled = checkbox.is(':checked'); - const action = enabled ? 'enable' : 'disable'; - const periodCell = checkbox.closest('tr').find('.period-cell'); - const periodDescription = periodCell.attr('title') || periodCell.text().trim(); - - const confirmMessage = `Are you sure you want to ${action} the periodic execution of "${taskName}" ${periodDescription}?`; - if (confirm(confirmMessage)) { - form.find('input[name="enabled"]').val(enabled ? 'true' : 'false'); - form.submit(); - } else { - checkbox.prop('checked', !enabled); - } - }); - - // ======================================== - // Event Handlers - Task Links - // ======================================== - - $('.task-link').click(function() { - const targetId = $(this).attr('href').substring(1); - const $targetRow = $('#' + targetId); - - if ($targetRow.length) { - $targetRow.addClass('highlighted-row'); - setTimeout(() => $targetRow.removeClass('highlighted-row'), 3000); - } - }); - - // ======================================== - // Event Handlers - Cron Expression Input - // ======================================== - - $('input[name="cron_expression"]').on('input change', function() { - updateCronDescription($(this)); - }); - - // ======================================== - // Event Handlers - New Task Creation - // ======================================== - - $('.discard-button').click(function() { - $('#add-periodic-task-button-container').show(); - $('#new-periodic-task-form').trigger('reset'); - $('.cron-description').text(''); - $('.interval-inputs').removeClass('hidden'); - $('.cron-inputs').addClass('hidden'); - $('.new-periodic-task-row .start-time-container').removeClass('hidden'); - $('#scheduled-tasks-table').removeClass('create-mode'); - }); - - $('.change-period-mode').click(function(e) { - e.preventDefault(); - togglePeriodMode($(this)); - }); - - $('#add-periodic-task-button').click(function() { - $('#add-periodic-task-button-container').hide(); - $('#scheduled-tasks-table').addClass('create-mode'); - - const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - initializeSelect2($('select[name="cron_timezone"]'), browserTimezone); - - const $newChainBuilder = $('.new-periodic-task-row .chain-builder'); - if ($newChainBuilder.length) { - const selectedTask = $('.new-periodic-task-row select[name="task"]').val(); - const instance = $newChainBuilder.data('chain-builder-instance'); - if (instance) { - instance.setTaskName(selectedTask); - } - } - }); - - $('.new-periodic-task-row select[name="task"]').on('change', function() { - const $newChainBuilder = $('.new-periodic-task-row .chain-builder'); - if ($newChainBuilder.length) { - const instance = $newChainBuilder.data('chain-builder-instance'); - if (instance) { - instance.setTaskName($(this).val()); - } - } - }); - - $('#new-periodic-task-form').on('submit', function(e) { - e.preventDefault(); - - const $newTaskRow = $('.new-periodic-task-row'); - const $cronDiv = $newTaskRow.find('.cron-inputs'); - const isCronMode = !$cronDiv.hasClass('hidden'); - - if (!validateScheduleInputs($newTaskRow, isCronMode)) { - return false; - } - - if (isCronMode) { - const $cronInput = $newTaskRow.find('input[name="cron_expression"]'); - if ($cronInput.length && !$cronInput.get(0).checkValidity()) { - $cronInput.get(0).reportValidity(); - return false; - } - } - - updateDateTimeInput($newTaskRow); - - const $runPythonForm = $("#run-python-send"); - if ($runPythonForm.length) { - if (!$runPythonForm.get(0).reportValidity()) { - return false; - } - - const runPythonFormData = $runPythonForm.serializeArray(); - runPythonFormData.forEach(function(data) { - if (data.name !== "csrf-token") { - const inputName = data.name === "payload" ? - "execute_request_payload" : - `execute_request_meta_${data.name.replace('meta_', '')}`; - - $('', { - type: 'hidden', - name: inputName, - value: data.value - }).appendTo("#new-periodic-task-form"); - } - }); - } - - const form = e.currentTarget; - const $newForm = $(form); - $newForm.find('input[name="execute_request_chain_task_names"]').remove(); - $newForm.find('input[name="execute_request_chain_on_failure"]').remove(); - const $newChainBuilder = $newTaskRow.find('.chain-builder'); - if ($newChainBuilder.length) { - const instance = $newChainBuilder.data('chain-builder-instance'); - if (instance) { - const chain = instance.getChain(); - if (chain.length > 0) { - $('', { - type: 'hidden', - name: 'execute_request_chain_task_names', - value: JSON.stringify(chain) - }).appendTo($newForm); - $('', { - type: 'hidden', - name: 'execute_request_chain_on_failure', - value: instance.getChainOnFailure() ? 'true' : 'false' - }).appendTo($newForm); - } - } - } - - form.submit(); - return true; - }); - - // ======================================== - // Event Handlers - Edit Mode - // ======================================== - - $('.edit-button').click(function() { - const taskId = $(this).data('task-id'); - const $currentRow = $(this).closest('tr'); - const $editRow = $(`.edit-periodic-task-row[data-task-id="${taskId}"]`); - - $('.edit-periodic-task-row').hide(); - $('#scheduled-tasks-table tbody > tr:not(.edit-periodic-task-row)').show(); - - $currentRow.hide(); - $editRow.show(); - - $editRow.find('input, select').each(function() { - if ($(this).attr('type') !== 'hidden') { - $(this).data('original-value', $(this).val()); - } - }); - - const $timezoneSelect = $editRow.find('select[name="cron_timezone"]'); - if (!$timezoneSelect.hasClass('select2-hidden-accessible')) { - initializeSelect2($timezoneSelect); - } - - const $cronInput = $editRow.find('input[name="cron_expression"]'); - if (!$cronInput.closest('.cron-inputs').hasClass('hidden')) { - updateCronDescription($cronInput); - } - }); - - $('.cancel-edit-button').click(function() { - const taskId = $(this).data('task-id'); - const $editRow = $(`.edit-periodic-task-row[data-task-id="${taskId}"]`); - const $currentRow = $editRow.prev('tr'); - - $editRow.hide(); - $currentRow.show(); - - $editRow.find('input, select').each(function() { - if ($(this).attr('type') === 'checkbox') { - const originalChecked = $currentRow.find('.enable-checkbox').is(':checked'); - $(this).prop('checked', originalChecked); - } else if ($(this).attr('type') !== 'hidden') { - // Reset other inputs to their original values - $(this).val($(this).data('original-value') || ''); - } - }); - }); - - // Capture phase runs before the global .submitButton confirm dialog (bubble), so - // schedule validation can block the modal and submission on invalid cron/interval. - document.addEventListener('click', function(e) { - const button = e.target.closest('.save-edit-button'); - if (!button) { - return; - } - - const taskId = $(button).data('task-id'); - const $editRow = $(`.edit-periodic-task-row[data-task-id="${taskId}"]`); - const $cronDiv = $editRow.find('.cron-inputs'); - const isCronMode = !$cronDiv.hasClass('hidden'); - - if (!validateScheduleInputs($editRow, isCronMode)) { - e.preventDefault(); - e.stopImmediatePropagation(); - return false; - } - - if (isCronMode) { - const $cronInput = $editRow.find('input[name="cron_expression"]'); - if ($cronInput.length && !$cronInput.get(0).checkValidity()) { - $cronInput.get(0).reportValidity(); - e.preventDefault(); - e.stopImmediatePropagation(); - return false; - } - } - - updateDateTimeInput($editRow); - - const $form = $(`#edit-periodic-task-form-${taskId}`); - - $form.find('input:not([name="csrf-token"])').remove(); - - const $chainBuilder = $editRow.find('.chain-builder'); - if ($chainBuilder.length) { - const instance = $chainBuilder.data('chain-builder-instance'); - if (instance) { - const chain = instance.getChain(); - if (chain.length > 0) { - $('', { - type: 'hidden', - name: 'execute_request_chain_task_names', - value: JSON.stringify(chain) - }).appendTo($form); - $('', { - type: 'hidden', - name: 'execute_request_chain_on_failure', - value: instance.getChainOnFailure() ? 'true' : 'false' - }).appendTo($form); - } - } - } - - const enabled = $editRow.find('.edit-periodic-task-enable-checkbox').is(':checked'); - $('', { - type: 'hidden', - name: 'enabled', - value: enabled ? 'true' : 'false' - }).appendTo($form); - - const startTimeValue = $editRow.find('input[name="start_time"]').val(); - if (startTimeValue) { - $('', { - type: 'hidden', - name: 'start_time', - value: startTimeValue - }).appendTo($form); - } - - if (isCronMode) { - const cronExpression = $editRow.find('input[name="cron_expression"]').val(); - const cronTimezone = $editRow.find('select[name="cron_timezone"]').val(); - if (cronExpression) { - $('', { - type: 'hidden', - name: 'cron_expression', - value: cronExpression - }).appendTo($form); - $('', { - type: 'hidden', - name: 'cron_timezone', - value: cronTimezone - }).appendTo($form); - } - } else { - const intervalEvery = $editRow.find('input[name="interval_every"]').val(); - const intervalPeriod = $editRow.find('select[name="interval_period"]').val(); - if (intervalEvery) { - $('', { - type: 'hidden', - name: 'interval_every', - value: intervalEvery - }).appendTo($form); - $('', { - type: 'hidden', - name: 'interval_period', - value: intervalPeriod - }).appendTo($form); - } - } - }, true); -}); diff --git a/static/js/schema-selector.js b/static/js/schema-selector.js deleted file mode 100644 index 21102a131b..0000000000 --- a/static/js/schema-selector.js +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Copyright (C) 2026 Percona LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -/** - * Shared utilities for AJAX-powered schema/table cascading dropdowns. - * Used by: alters, archiver, mysql_backups/restore, checksums (create + edit forms). - */ - -async function fetchSchemas(serviceId, search) { - var params = new URLSearchParams(); - if (search) params.set("search", search); - var url = "/inventory-api/services/" + serviceId + "/schemas?" + params; - var response = await fetch(url); - if (!response.ok) return []; - return response.json(); -} - -async function fetchTables(schemaId, search) { - var params = new URLSearchParams(); - if (search) params.set("search", search); - var url = "/inventory-api/schemas/" + schemaId + "/tables?" + params; - var response = await fetch(url); - if (!response.ok) return []; - return response.json(); -} - -async function fetchServices(serviceType) { - var params = new URLSearchParams(); - if (serviceType) params.set("service_type", serviceType); - params.set("limit", "0"); - var url = "/inventory-api/services/?" + params; - var response = await fetch(url); - if (!response.ok) return []; - var data = await response.json(); - return data.items || []; -} - -async function populateDestSchemas(destServiceSelect, destDbSelect, opts) { - var options = opts || {}; - var matched = options.matched || {}; - var unknownLabel = options.unknownLabel || null; - populateSelect(destDbSelect, [], { - placeholder: "Select a schema" - }); - if (destServiceSelect.value && destServiceSelect.value !== "-1") { - var schemas = options.schemas || await fetchSchemas(destServiceSelect.value); - if (schemas.length > 0) { - populateSelect(destDbSelect, schemas, { - placeholder: "Select a schema" - }); - destDbSelect.disabled = false; - var serviceMatch = String(destServiceSelect.value) === String(matched.destServiceId); - if (serviceMatch && matched.destSchemaId && matched.destSchemaId !== "-1") { - destDbSelect.value = matched.destSchemaId; - return; - } - if (serviceMatch && matched.destSchemaId === "-1" && unknownLabel) { - var unknownOpt = document.createElement("option"); - unknownOpt.value = "-1"; - unknownOpt.textContent = unknownLabel; - unknownOpt.selected = true; - destDbSelect.appendChild(unknownOpt); - } - if (schemas.length === 1) { - destDbSelect.value = schemas[0].id; - } - } else { - destDbSelect.disabled = true; - } - } else { - destDbSelect.disabled = true; - } -} - -function setManualInputDestHost(enabled, forceToggleChecked, els) { - if (forceToggleChecked && els.toggle) { - els.toggle.checked = enabled; - } - if (enabled) { - els.serviceFieldsContainer.style.display = "none"; - els.serviceSelect.value = ""; - els.serviceSelect.disabled = true; - els.manualInputFields.style.display = "block"; - els.hostInput.disabled = false; - els.portInput.disabled = false; - els.dbNameInput.disabled = false; - } else { - els.serviceFieldsContainer.style.display = "block"; - els.serviceSelect.disabled = false; - els.manualInputFields.style.display = "none"; - els.hostInput.disabled = true; - els.portInput.disabled = true; - els.dbNameInput.disabled = true; - els.hostInput.value = ""; - els.portInput.value = ""; - els.dbNameInput.value = ""; - } -} - -function populateSelect(selectEl, items, options) { - var opts = options || {}; - var valueKey = opts.valueKey || "id"; - var textKey = opts.textKey || "name"; - var placeholder = opts.placeholder || "Not selected"; - var emptyPlaceholder = opts.emptyPlaceholder || "None available"; - var preselectValue = opts.preselectValue || null; - - var hasItems = items.length > 0; - selectEl.disabled = !hasItems; - - while (selectEl.firstChild) { - selectEl.removeChild(selectEl.firstChild); - } - - var defaultOpt = document.createElement("option"); - defaultOpt.value = "-1"; - defaultOpt.selected = true; - defaultOpt.textContent = hasItems ? placeholder : emptyPlaceholder; - selectEl.appendChild(defaultOpt); - - for (var i = 0; i < items.length; i++) { - var item = items[i]; - var opt = document.createElement("option"); - opt.value = item[valueKey]; - opt.textContent = item[textKey]; - if (preselectValue !== null && String(item[valueKey]) === String(preselectValue)) { - opt.selected = true; - } - selectEl.appendChild(opt); - } -} diff --git a/static/js/schemaDetail.js b/static/js/schemaDetail.js deleted file mode 100644 index b103c62902..0000000000 --- a/static/js/schemaDetail.js +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Copyright (C) 2026 Percona LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -function isOverflowing(element) { - return element.scrollHeight > element.offsetHeight; -} - -function initializeToggleButtons() { - const ellipsedElements = document.querySelectorAll('.ellipse'); - - ellipsedElements.forEach(element => { - const buttonId = element.id.replace('create-', 'create-btn-').replace('keys-', 'keys-btn-'); - const button = document.getElementById(buttonId); - - if (!button) { - console.error(`Button with ID '${buttonId}' not found.`); - return; - } - - button.addEventListener('click', function() { - const targetId = button.getAttribute('data-target'); - toggleContent(targetId, button); - }); - - if (isOverflowing(element)) { - button.classList.remove('hidden'); - } else { - button.classList.add('hidden'); - element.classList.add('ellipse'); - } - }); -} - -function toggleContent(id, button) { - const element = document.getElementById(id); - - if (!element) { - console.error(`Element with ID '${id}' not found.`); - return; - } - - if (element.classList.contains('ellipse')) { - element.classList.remove('ellipse'); - button.textContent = '< { - initializeToggleButtons(); -}); diff --git a/static/js/snippets.js b/static/js/snippets.js deleted file mode 100644 index ef88ade10b..0000000000 --- a/static/js/snippets.js +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Copyright (C) 2026 Percona LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -$(document).ready(function() { - $('a.downloadSnippet').click(function() { - const $anchor = $(this); - const snippetFilename = $.escapeSelector($anchor.data('snippet-filename')); - const $approveButton = $(`#${snippetFilename}-approval > button[type=submit]:is([disabled])`); - $approveButton.prop("disabled", false); - $approveButton.attr("title", $approveButton.data("next-title")); - }) -}); - -$(document).ready(function() { - const $checkboxes = $('.snippetCheckbox'); - const $selectAll = $('#selectAllSnippets'); - const $bulkBar = $('#snippetsBulkBar'); - if (!$checkboxes.length) { - return; - } - - function updateBulkBar() { - const checkedCount = $checkboxes.filter(':checked').length; - const total = $checkboxes.length; - $bulkBar.prop('hidden', checkedCount === 0); - $selectAll.prop('checked', checkedCount > 0 && checkedCount === total); - $selectAll.prop('indeterminate', checkedCount > 0 && checkedCount < total); - } - - $selectAll.on('change', function() { - $checkboxes.prop('checked', this.checked); - updateBulkBar(); - }); - $checkboxes.on('change', updateBulkBar); - updateBulkBar(); -}); diff --git a/static/js/troubleshooting-detail.js b/static/js/troubleshooting-detail.js deleted file mode 100644 index 9f59b016ed..0000000000 --- a/static/js/troubleshooting-detail.js +++ /dev/null @@ -1,182 +0,0 @@ -/** - * Copyright (C) 2026 Percona LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -/** - * AJAX snippet execution, polling, and output rendering for the - * Alert Troubleshooting detail page. - */ -(function() { - 'use strict'; - - var POLL_INITIAL_MS = 2000; - var POLL_BACKOFF_MS = 5000; - var BACKOFF_AFTER_MS = 30000; - var HOSTNAME_FIELD = '-hostname-'; - - function getBaseUri() { - var cards = document.querySelector('.at-snippet-cards[data-base-uri]'); - if (cards && cards.dataset.baseUri) return cards.dataset.baseUri; - var path = window.location.pathname; - var parts = path.split('/'); - parts.pop(); - return parts.join('/'); - } - - function getSharedHost() { - var sel = document.getElementById('atSharedHost'); - return sel ? sel.value : ''; - } - - function setCardState(card, state, message) { - var feedback = card.querySelector('.at-run-feedback'); - if (!feedback) { - feedback = document.createElement('div'); - feedback.className = 'at-run-feedback'; - var formSection = card.querySelector('.at-snippet-form'); - if (formSection) { - formSection.appendChild(feedback); - } - } - feedback.className = 'at-run-feedback'; - if (state === 'error') { - feedback.classList.add('at-run-error'); - } else if (state === 'success') { - feedback.classList.add('at-run-success'); - } - feedback.textContent = message || ''; - } - - function setButtonLoading(form, loading) { - var btn = form.querySelector('button[type="submit"]'); - if (!btn) return; - btn.disabled = loading; - if (loading) { - btn.dataset.originalHtml = btn.innerHTML; - btn.textContent = 'Running…'; - form.classList.add('at-run-loading'); - } else { - if (btn.dataset.originalHtml) { - btn.innerHTML = btn.dataset.originalHtml; - } - form.classList.remove('at-run-loading'); - } - } - - function showOutput(card, text) { - var area = card.querySelector('.at-output-area'); - var pre = card.querySelector('.at-output-pre'); - if (!area || !pre) return; - area.style.display = ''; - pre.textContent = text; - pre.scrollTop = pre.scrollHeight; - } - - function pollOutput(card, form, taskId) { - var baseUri = getBaseUri(); - var startTime = Date.now(); - - function doPoll() { - fetch(baseUri + '/output/' + encodeURIComponent(taskId), { - credentials: 'include' - }).then(function(res) { - if (!res.ok) { - throw new Error('Server returned ' + res.status); - } - return res.json(); - }).then(function(data) { - if (data.output) { - showOutput(card, data.output); - } - if (data.status === 'running' || data.status === 'pending') { - var elapsed = Date.now() - startTime; - var interval = elapsed > BACKOFF_AFTER_MS ? POLL_BACKOFF_MS : POLL_INITIAL_MS; - setTimeout(doPoll, interval); - } else { - if (!data.output && data.error) { - showOutput(card, data.error); - } else if (!data.output) { - showOutput(card, ''); - } - setButtonLoading(form, false); - if (data.status === 'success') { - setCardState(card, 'success', 'Completed'); - } else if (data.status === 'failed') { - setCardState(card, 'error', 'Execution failed'); - } else if (data.status === 'stopped') { - setCardState(card, 'error', 'Execution stopped'); - } else { - setCardState(card, 'error', 'Unexpected status: ' + data.status); - } - } - }).catch(function() { - setButtonLoading(form, false); - setCardState(card, 'error', 'Failed to retrieve output'); - }); - } - - doPoll(); - } - - function handleFormSubmit(e) { - e.preventDefault(); - var form = e.target; - var card = form.closest('.at-snippet-card'); - if (!card) return; - - var host = getSharedHost(); - if (!host) { - setCardState(card, 'error', 'Please select an executor host'); - return; - } - - setCardState(card, '', ''); - setButtonLoading(form, true); - - var formData = new FormData(form); - formData.set(HOSTNAME_FIELD, host); - - fetch(form.action, { - method: 'POST', - body: formData, - credentials: 'include' - }).then(function(res) { - return res.json().then(function(data) { - return { - ok: res.ok, - data: data - }; - }); - }).then(function(result) { - if (!result.ok || result.data.error) { - setButtonLoading(form, false); - setCardState(card, 'error', result.data.error || 'Execution failed'); - return; - } - showOutput(card, 'Running…'); - pollOutput(card, form, result.data.task_id); - }).catch(function() { - setButtonLoading(form, false); - setCardState(card, 'error', 'Network error'); - }); - } - - document.addEventListener('DOMContentLoaded', function() { - document.querySelectorAll('.at-snippet-card form').forEach(function(form) { - form.addEventListener('submit', handleFormSubmit); - }); - }); -})(); diff --git a/static/js/vendor/cronstrue.min.js b/static/js/vendor/cronstrue.min.js deleted file mode 100644 index b1f81272df..0000000000 --- a/static/js/vendor/cronstrue.min.js +++ /dev/null @@ -1 +0,0 @@ -!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define("cronstrue",[],e):"object"==typeof exports?exports.cronstrue=e():t.cronstrue=e()}(globalThis,(()=>(()=>{"use strict";var t={949:(t,e,n)=>{Object.defineProperty(e,"__esModule",{value:!0}),e.CronParser=void 0;var r=n(515),i=function(){function t(t,e,n){void 0===e&&(e=!0),void 0===n&&(n=!1),this.expression=t,this.dayOfWeekStartIndexZero=e,this.monthStartIndexZero=n}return t.prototype.parse=function(){var t,e;if((null!==(t=this.expression)&&void 0!==t?t:"").startsWith("@")){var n=this.parseSpecial(this.expression);e=this.extractParts(n)}else e=this.extractParts(this.expression);return this.normalize(e),this.validate(e),e},t.prototype.parseSpecial=function(t){var e={"@yearly":"0 0 1 1 *","@annually":"0 0 1 1 *","@monthly":"0 0 1 * *","@weekly":"0 0 * * 0","@daily":"0 0 * * *","@midnight":"0 0 * * *","@hourly":"0 * * * *"}[t];if(!e)throw new Error("Unknown special expression.");return e},t.prototype.extractParts=function(t){if(!this.expression)throw new Error("cron expression is empty");for(var e=t.trim().split(/[ ]+/),n=0;n7)throw new Error("Expression has ".concat(e.length," parts; too many!"));return e},t.prototype.normalize=function(t){var e=this;if(t[3]=t[3].replace("?","*"),t[5]=t[5].replace("?","*"),t[2]=t[2].replace("?","*"),0==t[0].indexOf("0/")&&(t[0]=t[0].replace("0/","*/")),0==t[1].indexOf("0/")&&(t[1]=t[1].replace("0/","*/")),0==t[2].indexOf("0/")&&(t[2]=t[2].replace("0/","*/")),0==t[3].indexOf("1/")&&(t[3]=t[3].replace("1/","*/")),0==t[4].indexOf("1/")&&(t[4]=t[4].replace("1/","*/")),0==t[6].indexOf("1/")&&(t[6]=t[6].replace("1/","*/")),t[5]=t[5].replace(/(^\d)|([^#/\s]\d)/g,(function(t){var n=t.replace(/\D/,""),r=n;return e.dayOfWeekStartIndexZero?"7"==n&&(r="0"):r=(parseInt(n)-1).toString(),t.replace(n,r)})),"L"==t[5]&&(t[5]="6"),"?"==t[3]&&(t[3]="*"),t[3].indexOf("W")>-1&&(t[3].indexOf(",")>-1||t[3].indexOf("-")>-1))throw new Error("The 'W' character can be specified only when the day-of-month is a single day, not a range or list of days.");var n={SUN:0,MON:1,TUE:2,WED:3,THU:4,FRI:5,SAT:6};for(var r in n)t[5]=t[5].replace(new RegExp(r,"gi"),n[r].toString());t[4]=t[4].replace(/(^\d{1,2})|([^#/\s]\d{1,2})/g,(function(t){var n=t.replace(/\D/,""),r=n;return e.monthStartIndexZero&&(r=(parseInt(n)+1).toString()),t.replace(n,r)}));var i={JAN:1,FEB:2,MAR:3,APR:4,MAY:5,JUN:6,JUL:7,AUG:8,SEP:9,OCT:10,NOV:11,DEC:12};for(var o in i)t[4]=t[4].replace(new RegExp(o,"gi"),i[o].toString());"0"==t[0]&&(t[0]=""),/\*|\-|\,|\//.test(t[2])||!/\*|\//.test(t[1])&&!/\*|\//.test(t[0])||(t[2]+="-".concat(t[2]));for(var s=0;s-1&&!/^\*|\-|\,/.test(t[s])){var a=null;switch(s){case 4:a="12";break;case 5:a="6";break;case 6:a="9999";break;default:a=null}if(null!==a){var u=t[s].split("/");t[s]="".concat(u[0],"-").concat(a,"/").concat(u[1])}}},t.prototype.validate=function(t){this.assertNoInvalidCharacters("DOW",t[5]),this.assertNoInvalidCharacters("DOM",t[3]),this.validateRange(t)},t.prototype.validateRange=function(t){r.default.secondRange(t[0]),r.default.minuteRange(t[1]),r.default.hourRange(t[2]),r.default.dayOfMonthRange(t[3]),r.default.monthRange(t[4],this.monthStartIndexZero),r.default.dayOfWeekRange(t[5],this.dayOfWeekStartIndexZero)},t.prototype.assertNoInvalidCharacters=function(t,e){var n=e.match(/[A-KM-VX-Z]+/gi);if(n&&n.length)throw new Error("".concat(t," part contains invalid values: '").concat(n.toString(),"'"))},t}();e.CronParser=i},333:(t,e,n)=>{Object.defineProperty(e,"__esModule",{value:!0}),e.ExpressionDescriptor=void 0;var r=n(823),i=n(949),o=function(){function t(e,n){if(this.expression=e,this.options=n,this.expressionParts=new Array(5),!this.options.locale&&t.defaultLocale&&(this.options.locale=t.defaultLocale),!t.locales[this.options.locale]){var r=Object.keys(t.locales)[0];console.warn("Locale '".concat(this.options.locale,"' could not be found; falling back to '").concat(r,"'.")),this.options.locale=r}this.i18n=t.locales[this.options.locale],void 0===n.use24HourTimeFormat&&(n.use24HourTimeFormat=this.i18n.use24HourTimeFormatByDefault())}return t.toString=function(e,n){var r=void 0===n?{}:n,i=r.throwExceptionOnParseError,o=void 0===i||i,s=r.verbose,a=void 0!==s&&s,u=r.dayOfWeekStartIndexZero,c=void 0===u||u,f=r.monthStartIndexZero,h=void 0!==f&&f,p=r.use24HourTimeFormat,l=r.locale,y=void 0===l?null:l,m=r.tzOffset;return new t(e,{throwExceptionOnParseError:o,verbose:a,dayOfWeekStartIndexZero:c,monthStartIndexZero:h,use24HourTimeFormat:p,locale:y,tzOffset:void 0===m?0:m}).getFullDescription()},t.initialize=function(e,n){void 0===n&&(n="en"),t.specialCharacters=["/","-",",","*"],t.defaultLocale=n,e.load(t.locales)},t.prototype.getFullDescription=function(){var t="";try{var e=new i.CronParser(this.expression,this.options.dayOfWeekStartIndexZero,this.options.monthStartIndexZero);this.expressionParts=e.parse();var n=this.getTimeOfDayDescription(),r=this.getDayOfMonthDescription(),o=this.getMonthDescription();t+=n+r+this.getDayOfWeekDescription()+o+this.getYearDescription(),t=(t=this.transformVerbosity(t,!!this.options.verbose)).charAt(0).toLocaleUpperCase()+t.substr(1)}catch(e){if(this.options.throwExceptionOnParseError)throw"".concat(e);t=this.i18n.anErrorOccuredWhenGeneratingTheExpressionD()}return t},t.prototype.getTimeOfDayDescription=function(){var e=this.expressionParts[0],n=this.expressionParts[1],i=this.expressionParts[2],o="";if(r.StringUtilities.containsAny(n,t.specialCharacters)||r.StringUtilities.containsAny(i,t.specialCharacters)||r.StringUtilities.containsAny(e,t.specialCharacters))if(e||!(n.indexOf("-")>-1)||n.indexOf(",")>-1||n.indexOf("/")>-1||r.StringUtilities.containsAny(i,t.specialCharacters))if(!e&&i.indexOf(",")>-1&&-1==i.indexOf("-")&&-1==i.indexOf("/")&&!r.StringUtilities.containsAny(n,t.specialCharacters)){var s=i.split(",");o+=this.i18n.at();for(var a=0;a1){var o=i[i.length-1].index;n=n.substring(0,o)+":59"+n.substring(o+3)}}return n},t.prototype.getDayOfWeekDescription=function(){var t=this,e=this.i18n.daysOfTheWeek();return"*"==this.expressionParts[5]?"":this.getSegmentDescription(this.expressionParts[5],this.i18n.commaEveryDay(),(function(n,r){var i=n;n.indexOf("#")>-1?i=n.substring(0,n.indexOf("#")):n.indexOf("L")>-1&&(i=i.replace("L",""));var o=parseInt(i);if(t.options.tzOffset){var s=t.expressionParts[2],a=parseInt(s)+(t.options.tzOffset?t.options.tzOffset:0);a>=24?o++:a<0&&o--,o>6?o=0:o<0&&(o=6)}var u=t.i18n.daysOfTheWeekInCase?t.i18n.daysOfTheWeekInCase(r)[o]:e[o];if(n.indexOf("#")>-1){var c=null,f=n.substring(n.indexOf("#")+1),h=n.substring(0,n.indexOf("#"));switch(f){case"1":c=t.i18n.first(h);break;case"2":c=t.i18n.second(h);break;case"3":c=t.i18n.third(h);break;case"4":c=t.i18n.fourth(h);break;case"5":c=t.i18n.fifth(h)}u=c+" "+u}return u}),(function(e){return 1==parseInt(e)?"":r.StringUtilities.format(t.i18n.commaEveryX0DaysOfTheWeek(e),e)}),(function(e){var n=e.substring(0,e.indexOf("-"));return"*"!=t.expressionParts[3]?t.i18n.commaAndX0ThroughX1(n):t.i18n.commaX0ThroughX1(n)}),(function(e){var n=null;if(e.indexOf("#")>-1){var r=e.substring(e.indexOf("#")+1),i=e.substring(0,e.indexOf("#"));n=t.i18n.commaOnThe(r,i).trim()+t.i18n.spaceX0OfTheMonth()}else if(e.indexOf("L")>-1)n=t.i18n.commaOnTheLastX0OfTheMonth(e.replace("L",""));else{n="*"!=t.expressionParts[3]?t.i18n.commaAndOnX0():t.i18n.commaOnlyOnX0(e)}return n}))},t.prototype.getMonthDescription=function(){var t=this,e=this.i18n.monthsOfTheYear();return this.getSegmentDescription(this.expressionParts[4],"",(function(n,r){return r&&t.i18n.monthsOfTheYearInCase?t.i18n.monthsOfTheYearInCase(r)[parseInt(n)-1]:e[parseInt(n)-1]}),(function(e){return 1==parseInt(e)?"":r.StringUtilities.format(t.i18n.commaEveryX0Months(e),e)}),(function(e){return t.i18n.commaMonthX0ThroughMonthX1()||t.i18n.commaX0ThroughX1()}),(function(e){return t.i18n.commaOnlyInMonthX0?t.i18n.commaOnlyInMonthX0():t.i18n.commaOnlyInX0()}))},t.prototype.getDayOfMonthDescription=function(){var t=this,e=null,n=this.expressionParts[3];switch(n){case"L":e=this.i18n.commaOnTheLastDayOfTheMonth();break;case"WL":case"LW":e=this.i18n.commaOnTheLastWeekdayOfTheMonth();break;default:var i=n.match(/(\d{1,2}W)|(W\d{1,2})/);if(i){var o=parseInt(i[0].replace("W","")),s=1==o?this.i18n.firstWeekday():r.StringUtilities.format(this.i18n.weekdayNearestDayX0(),o.toString());e=r.StringUtilities.format(this.i18n.commaOnTheX0OfTheMonth(),s);break}var a=n.match(/L-(\d{1,2})/);if(a){var u=a[1];e=r.StringUtilities.format(this.i18n.commaDaysBeforeTheLastDayOfTheMonth(u),u);break}if("*"==n&&"*"!=this.expressionParts[5])return"";e=this.getSegmentDescription(n,this.i18n.commaEveryDay(),(function(e){return"L"==e?t.i18n.lastDay():t.i18n.dayX0?r.StringUtilities.format(t.i18n.dayX0(),e):e}),(function(e){return"1"==e?t.i18n.commaEveryDay():t.i18n.commaEveryX0Days(e)}),(function(e){return t.i18n.commaBetweenDayX0AndX1OfTheMonth(e)}),(function(e){return t.i18n.commaOnDayX0OfTheMonth(e)}))}return e},t.prototype.getYearDescription=function(){var t=this;return this.getSegmentDescription(this.expressionParts[6],"",(function(t){return/^\d+$/.test(t)?new Date(parseInt(t),1).getFullYear().toString():t}),(function(e){return r.StringUtilities.format(t.i18n.commaEveryX0Years(e),e)}),(function(e){return t.i18n.commaYearX0ThroughYearX1()||t.i18n.commaX0ThroughX1()}),(function(e){return t.i18n.commaOnlyInYearX0?t.i18n.commaOnlyInYearX0():t.i18n.commaOnlyInX0()}))},t.prototype.getSegmentDescription=function(t,e,n,i,o,s){var a=null,u=t.indexOf("/")>-1,c=t.indexOf("-")>-1,f=t.indexOf(",")>-1;if(t)if("*"===t)a=e;else if(u||c||f)if(f){for(var h=t.split(","),p="",l=0;l0&&h.length>2&&(p+=",",l0&&h.length>1&&(l==h.length-1||2==h.length)&&(p+="".concat(this.i18n.spaceAnd()," ")),h[l].indexOf("/")>-1||h[l].indexOf("-")>-1){var y=h[l].indexOf("-")>-1&&-1==h[l].indexOf("/"),m=this.getSegmentDescription(h[l],e,n,i,y?this.i18n.commaX0ThroughX1:o,s);y&&(m=m.replace(", ","")),p+=m}else p+=u?this.getSegmentDescription(h[l],e,n,i,o,s):n(h[l]);a=u?p:r.StringUtilities.format(s(t),p)}else if(u){h=t.split("/");if(a=r.StringUtilities.format(i(h[1]),h[1]),h[0].indexOf("-")>-1){var d=this.generateRangeSegmentDescription(h[0],o,n);0!=d.indexOf(", ")&&(a+=", "),a+=d}else if(-1==h[0].indexOf("*")){var g=r.StringUtilities.format(s(h[0]),n(h[0]));g=g.replace(", ",""),a+=r.StringUtilities.format(this.i18n.commaStartingX0(),g)}}else c&&(a=this.generateRangeSegmentDescription(t,o,n));else a=r.StringUtilities.format(s(t),n(t));else a="";return a},t.prototype.generateRangeSegmentDescription=function(t,e,n){var i="",o=t.split("-"),s=n(o[0],1),a=n(o[1],2),u=e(t);return i+=r.StringUtilities.format(u,s,a)},t.prototype.formatTime=function(t,e,n){var r=0,i=0;this.options.tzOffset&&(r=this.options.tzOffset>0?Math.floor(this.options.tzOffset):Math.ceil(this.options.tzOffset),0!=(i=parseFloat((this.options.tzOffset%1).toFixed(2)))&&(i*=60));var o=parseInt(t)+r,s=parseInt(e)+i;s>=60?(s-=60,o+=1):s<0&&(s+=60,o-=1),o>=24?o-=24:o<0&&(o=24+o);var a="",u=!1;this.options.use24HourTimeFormat||(a=(u=!(!this.i18n.setPeriodBeforeTime||!this.i18n.setPeriodBeforeTime()))?"".concat(this.getPeriod(o)," "):" ".concat(this.getPeriod(o)),o>12&&(o-=12),0===o&&(o=12));var c="";return n&&(c=":".concat(("00"+n).substring(n.length))),"".concat(u?a:"").concat(("00"+o.toString()).substring(o.toString().length),":").concat(("00"+s.toString()).substring(s.toString().length)).concat(c).concat(u?"":a)},t.prototype.transformVerbosity=function(t,e){return e||(t=(t=(t=(t=t.replace(new RegExp(", ".concat(this.i18n.everyMinute()),"g"),"")).replace(new RegExp(", ".concat(this.i18n.everyHour()),"g"),"")).replace(new RegExp(this.i18n.commaEveryDay(),"g"),"")).replace(/\, ?$/,"")),t},t.prototype.getPeriod=function(t){return t>=12?this.i18n.pm&&this.i18n.pm()||"PM":this.i18n.am&&this.i18n.am()||"AM"},t.locales={},t}();e.ExpressionDescriptor=o},747:(t,e,n)=>{Object.defineProperty(e,"__esModule",{value:!0}),e.enLocaleLoader=void 0;var r=n(486),i=function(){function t(){}return t.prototype.load=function(t){t.en=new r.en},t}();e.enLocaleLoader=i},486:(t,e)=>{Object.defineProperty(e,"__esModule",{value:!0}),e.en=void 0;var n=function(){function t(){}return t.prototype.atX0SecondsPastTheMinuteGt20=function(){return null},t.prototype.atX0MinutesPastTheHourGt20=function(){return null},t.prototype.commaMonthX0ThroughMonthX1=function(){return null},t.prototype.commaYearX0ThroughYearX1=function(){return null},t.prototype.use24HourTimeFormatByDefault=function(){return!1},t.prototype.anErrorOccuredWhenGeneratingTheExpressionD=function(){return"An error occured when generating the expression description. Check the cron expression syntax."},t.prototype.everyMinute=function(){return"every minute"},t.prototype.everyHour=function(){return"every hour"},t.prototype.atSpace=function(){return"At "},t.prototype.everyMinuteBetweenX0AndX1=function(){return"Every minute between %s and %s"},t.prototype.at=function(){return"At"},t.prototype.spaceAnd=function(){return" and"},t.prototype.everySecond=function(){return"every second"},t.prototype.everyX0Seconds=function(){return"every %s seconds"},t.prototype.secondsX0ThroughX1PastTheMinute=function(){return"seconds %s through %s past the minute"},t.prototype.atX0SecondsPastTheMinute=function(){return"at %s seconds past the minute"},t.prototype.everyX0Minutes=function(){return"every %s minutes"},t.prototype.minutesX0ThroughX1PastTheHour=function(){return"minutes %s through %s past the hour"},t.prototype.atX0MinutesPastTheHour=function(){return"at %s minutes past the hour"},t.prototype.everyX0Hours=function(){return"every %s hours"},t.prototype.betweenX0AndX1=function(){return"between %s and %s"},t.prototype.atX0=function(){return"at %s"},t.prototype.commaEveryDay=function(){return", every day"},t.prototype.commaEveryX0DaysOfTheWeek=function(){return", every %s days of the week"},t.prototype.commaX0ThroughX1=function(){return", %s through %s"},t.prototype.commaAndX0ThroughX1=function(){return", %s through %s"},t.prototype.first=function(){return"first"},t.prototype.second=function(){return"second"},t.prototype.third=function(){return"third"},t.prototype.fourth=function(){return"fourth"},t.prototype.fifth=function(){return"fifth"},t.prototype.commaOnThe=function(){return", on the "},t.prototype.spaceX0OfTheMonth=function(){return" %s of the month"},t.prototype.lastDay=function(){return"the last day"},t.prototype.commaOnTheLastX0OfTheMonth=function(){return", on the last %s of the month"},t.prototype.commaOnlyOnX0=function(){return", only on %s"},t.prototype.commaAndOnX0=function(){return", and on %s"},t.prototype.commaEveryX0Months=function(){return", every %s months"},t.prototype.commaOnlyInX0=function(){return", only in %s"},t.prototype.commaOnTheLastDayOfTheMonth=function(){return", on the last day of the month"},t.prototype.commaOnTheLastWeekdayOfTheMonth=function(){return", on the last weekday of the month"},t.prototype.commaDaysBeforeTheLastDayOfTheMonth=function(){return", %s days before the last day of the month"},t.prototype.firstWeekday=function(){return"first weekday"},t.prototype.weekdayNearestDayX0=function(){return"weekday nearest day %s"},t.prototype.commaOnTheX0OfTheMonth=function(){return", on the %s of the month"},t.prototype.commaEveryX0Days=function(){return", every %s days"},t.prototype.commaBetweenDayX0AndX1OfTheMonth=function(){return", between day %s and %s of the month"},t.prototype.commaOnDayX0OfTheMonth=function(){return", on day %s of the month"},t.prototype.commaEveryHour=function(){return", every hour"},t.prototype.commaEveryX0Years=function(){return", every %s years"},t.prototype.commaStartingX0=function(){return", starting %s"},t.prototype.daysOfTheWeek=function(){return["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"]},t.prototype.monthsOfTheYear=function(){return["January","February","March","April","May","June","July","August","September","October","November","December"]},t}();e.en=n},515:(t,e)=>{function n(t,e){if(!t)throw new Error(e)}Object.defineProperty(e,"__esModule",{value:!0});var r=function(){function t(){}return t.secondRange=function(t){for(var e=t.split(","),r=0;r=0&&i<=59,"seconds part must be >= 0 and <= 59")}},t.minuteRange=function(t){for(var e=t.split(","),r=0;r=0&&i<=59,"minutes part must be >= 0 and <= 59")}},t.hourRange=function(t){for(var e=t.split(","),r=0;r=0&&i<=23,"hours part must be >= 0 and <= 23")}},t.dayOfMonthRange=function(t){for(var e=t.split(","),r=0;r=1&&i<=31,"DOM part must be >= 1 and <= 31")}},t.monthRange=function(t,e){for(var r=t.split(","),i=0;i=1&&o<=12,e?"month part must be >= 0 and <= 11":"month part must be >= 1 and <= 12")}},t.dayOfWeekRange=function(t,e){for(var r=t.split(","),i=0;i=0&&o<=6,e?"DOW part must be >= 0 and <= 6":"DOW part must be >= 1 and <= 7")}},t}();e.default=r},823:(t,e)=>{Object.defineProperty(e,"__esModule",{value:!0}),e.StringUtilities=void 0;var n=function(){function t(){}return t.format=function(t){for(var e=[],n=1;n-1}))},t}();e.StringUtilities=n}},e={};function n(r){var i=e[r];if(void 0!==i)return i.exports;var o=e[r]={exports:{}};return t[r](o,o.exports,n),o.exports}var r={};return(()=>{var t=r;Object.defineProperty(t,"__esModule",{value:!0}),t.toString=void 0;var e=n(333),i=n(747);e.ExpressionDescriptor.initialize(new i.enLocaleLoader),t.default=e.ExpressionDescriptor;var o=e.ExpressionDescriptor.toString;t.toString=o})(),r})())); diff --git a/static/js/vendor/floating-ui.core.umd.min.js b/static/js/vendor/floating-ui.core.umd.min.js deleted file mode 100644 index 11a20a1c8b..0000000000 --- a/static/js/vendor/floating-ui.core.umd.min.js +++ /dev/null @@ -1 +0,0 @@ -!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).FloatingUICore={})}(this,(function(t){"use strict";const e=["top","right","bottom","left"],n=["start","end"],i=e.reduce(((t,e)=>t.concat(e,e+"-"+n[0],e+"-"+n[1])),[]),o=Math.min,r=Math.max,a={left:"right",right:"left",bottom:"top",top:"bottom"},l={start:"end",end:"start"};function s(t,e,n){return r(t,o(e,n))}function f(t,e){return"function"==typeof t?t(e):t}function c(t){return t.split("-")[0]}function u(t){return t.split("-")[1]}function m(t){return"x"===t?"y":"x"}function d(t){return"y"===t?"height":"width"}function g(t){return["top","bottom"].includes(c(t))?"y":"x"}function p(t){return m(g(t))}function h(t,e,n){void 0===n&&(n=!1);const i=u(t),o=p(t),r=d(o);let a="x"===o?i===(n?"end":"start")?"right":"left":"start"===i?"bottom":"top";return e.reference[r]>e.floating[r]&&(a=w(a)),[a,w(a)]}function y(t){return t.replace(/start|end/g,(t=>l[t]))}function w(t){return t.replace(/left|right|bottom|top/g,(t=>a[t]))}function x(t){return"number"!=typeof t?function(t){return{top:0,right:0,bottom:0,left:0,...t}}(t):{top:t,right:t,bottom:t,left:t}}function v(t){const{x:e,y:n,width:i,height:o}=t;return{width:i,height:o,top:n,left:e,right:e+i,bottom:n+o,x:e,y:n}}function b(t,e,n){let{reference:i,floating:o}=t;const r=g(e),a=p(e),l=d(a),s=c(e),f="y"===r,m=i.x+i.width/2-o.width/2,h=i.y+i.height/2-o.height/2,y=i[l]/2-o[l]/2;let w;switch(s){case"top":w={x:m,y:i.y-o.height};break;case"bottom":w={x:m,y:i.y+i.height};break;case"right":w={x:i.x+i.width,y:h};break;case"left":w={x:i.x-o.width,y:h};break;default:w={x:i.x,y:i.y}}switch(u(e)){case"start":w[a]-=y*(n&&f?-1:1);break;case"end":w[a]+=y*(n&&f?-1:1)}return w}async function A(t,e){var n;void 0===e&&(e={});const{x:i,y:o,platform:r,rects:a,elements:l,strategy:s}=t,{boundary:c="clippingAncestors",rootBoundary:u="viewport",elementContext:m="floating",altBoundary:d=!1,padding:g=0}=f(e,t),p=x(g),h=l[d?"floating"===m?"reference":"floating":m],y=v(await r.getClippingRect({element:null==(n=await(null==r.isElement?void 0:r.isElement(h)))||n?h:h.contextElement||await(null==r.getDocumentElement?void 0:r.getDocumentElement(l.floating)),boundary:c,rootBoundary:u,strategy:s})),w="floating"===m?{x:i,y:o,width:a.floating.width,height:a.floating.height}:a.reference,b=await(null==r.getOffsetParent?void 0:r.getOffsetParent(l.floating)),A=await(null==r.isElement?void 0:r.isElement(b))&&await(null==r.getScale?void 0:r.getScale(b))||{x:1,y:1},R=v(r.convertOffsetParentRelativeRectToViewportRelativeRect?await r.convertOffsetParentRelativeRectToViewportRelativeRect({elements:l,rect:w,offsetParent:b,strategy:s}):w);return{top:(y.top-R.top+p.top)/A.y,bottom:(R.bottom-y.bottom+p.bottom)/A.y,left:(y.left-R.left+p.left)/A.x,right:(R.right-y.right+p.right)/A.x}}function R(t,e){return{top:t.top-e.height,right:t.right-e.width,bottom:t.bottom-e.height,left:t.left-e.width}}function P(t){return e.some((e=>t[e]>=0))}function D(t){const e=o(...t.map((t=>t.left))),n=o(...t.map((t=>t.top)));return{x:e,y:n,width:r(...t.map((t=>t.right)))-e,height:r(...t.map((t=>t.bottom)))-n}}t.arrow=t=>({name:"arrow",options:t,async fn(e){const{x:n,y:i,placement:r,rects:a,platform:l,elements:c,middlewareData:m}=e,{element:g,padding:h=0}=f(t,e)||{};if(null==g)return{};const y=x(h),w={x:n,y:i},v=p(r),b=d(v),A=await l.getDimensions(g),R="y"===v,P=R?"top":"left",D=R?"bottom":"right",T=R?"clientHeight":"clientWidth",O=a.reference[b]+a.reference[v]-w[v]-a.floating[b],E=w[v]-a.reference[v],L=await(null==l.getOffsetParent?void 0:l.getOffsetParent(g));let k=L?L[T]:0;k&&await(null==l.isElement?void 0:l.isElement(L))||(k=c.floating[T]||a.floating[b]);const C=O/2-E/2,B=k/2-A[b]/2-1,H=o(y[P],B),S=o(y[D],B),F=H,j=k-A[b]-S,z=k/2-A[b]/2+C,M=s(F,z,j),V=!m.arrow&&null!=u(r)&&z!==M&&a.reference[b]/2-(zu(e)===t)),...n.filter((e=>u(e)!==t))]:n.filter((t=>c(t)===t))).filter((n=>!t||u(n)===t||!!e&&y(n)!==n))}(p||null,x,w):w,R=await A(e,v),P=(null==(n=l.autoPlacement)?void 0:n.index)||0,D=b[P];if(null==D)return{};const T=h(D,a,await(null==m.isRTL?void 0:m.isRTL(d.floating)));if(s!==D)return{reset:{placement:b[0]}};const O=[R[c(D)],R[T[0]],R[T[1]]],E=[...(null==(o=l.autoPlacement)?void 0:o.overflows)||[],{placement:D,overflows:O}],L=b[P+1];if(L)return{data:{index:P+1,overflows:E},reset:{placement:L}};const k=E.map((t=>{const e=u(t.placement);return[t.placement,e&&g?t.overflows.slice(0,2).reduce(((t,e)=>t+e),0):t.overflows[0],t.overflows]})).sort(((t,e)=>t[1]-e[1])),C=(null==(r=k.filter((t=>t[2].slice(0,u(t[0])?2:3).every((t=>t<=0))))[0])?void 0:r[0])||k[0][0];return C!==s?{data:{index:P+1,overflows:E},reset:{placement:C}}:{}}}},t.computePosition=async(t,e,n)=>{const{placement:i="bottom",strategy:o="absolute",middleware:r=[],platform:a}=n,l=r.filter(Boolean),s=await(null==a.isRTL?void 0:a.isRTL(e));let f=await a.getElementRects({reference:t,floating:e,strategy:o}),{x:c,y:u}=b(f,i,s),m=i,d={},g=0;for(let n=0;nt+"-"+o)),e&&(r=r.concat(r.map(y)))),r}(l,R,b,E));const C=[l,...L],B=await A(e,P),H=[];let S=(null==(i=r.flip)?void 0:i.overflows)||[];if(d&&H.push(B[D]),p){const t=h(o,a,E);H.push(B[t[0]],B[t[1]])}if(S=[...S,{placement:o,overflows:H}],!H.every((t=>t<=0))){var F,j;const t=((null==(F=r.flip)?void 0:F.index)||0)+1,e=C[t];if(e)return{data:{index:t,overflows:S},reset:{placement:e}};let n=null==(j=S.filter((t=>t.overflows[0]<=0)).sort(((t,e)=>t.overflows[1]-e.overflows[1]))[0])?void 0:j.placement;if(!n)switch(v){case"bestFit":{var z;const t=null==(z=S.filter((t=>{if(k){const e=g(t.placement);return e===T||"y"===e}return!0})).map((t=>[t.placement,t.overflows.filter((t=>t>0)).reduce(((t,e)=>t+e),0)])).sort(((t,e)=>t[1]-e[1]))[0])?void 0:z[0];t&&(n=t);break}case"initialPlacement":n=l}if(o!==n)return{reset:{placement:n}}}return{}}}},t.hide=function(t){return void 0===t&&(t={}),{name:"hide",options:t,async fn(e){const{rects:n}=e,{strategy:i="referenceHidden",...o}=f(t,e);switch(i){case"referenceHidden":{const t=R(await A(e,{...o,elementContext:"reference"}),n.reference);return{data:{referenceHiddenOffsets:t,referenceHidden:P(t)}}}case"escaped":{const t=R(await A(e,{...o,altBoundary:!0}),n.floating);return{data:{escapedOffsets:t,escaped:P(t)}}}default:return{}}}}},t.inline=function(t){return void 0===t&&(t={}),{name:"inline",options:t,async fn(e){const{placement:n,elements:i,rects:a,platform:l,strategy:s}=e,{padding:u=2,x:m,y:d}=f(t,e),p=Array.from(await(null==l.getClientRects?void 0:l.getClientRects(i.reference))||[]),h=function(t){const e=t.slice().sort(((t,e)=>t.y-e.y)),n=[];let i=null;for(let t=0;ti.height/2?n.push([o]):n[n.length-1].push(o),i=o}return n.map((t=>v(D(t))))}(p),y=v(D(p)),w=x(u);const b=await l.getElementRects({reference:{getBoundingClientRect:function(){if(2===h.length&&h[0].left>h[1].right&&null!=m&&null!=d)return h.find((t=>m>t.left-w.left&&mt.top-w.top&&d=2){if("y"===g(n)){const t=h[0],e=h[h.length-1],i="top"===c(n),o=t.top,r=e.bottom,a=i?t.left:e.left,l=i?t.right:e.right;return{top:o,bottom:r,left:a,right:l,width:l-a,height:r-o,x:a,y:o}}const t="left"===c(n),e=r(...h.map((t=>t.right))),i=o(...h.map((t=>t.left))),a=h.filter((n=>t?n.left===i:n.right===e)),l=a[0].top,s=a[a.length-1].bottom;return{top:l,bottom:s,left:i,right:e,width:e-i,height:s-l,x:i,y:l}}return y}},floating:i.floating,strategy:s});return a.reference.x!==b.reference.x||a.reference.y!==b.reference.y||a.reference.width!==b.reference.width||a.reference.height!==b.reference.height?{reset:{rects:b}}:{}}}},t.limitShift=function(t){return void 0===t&&(t={}),{options:t,fn(e){const{x:n,y:i,placement:o,rects:r,middlewareData:a}=e,{offset:l=0,mainAxis:s=!0,crossAxis:u=!0}=f(t,e),d={x:n,y:i},p=g(o),h=m(p);let y=d[h],w=d[p];const x=f(l,e),v="number"==typeof x?{mainAxis:x,crossAxis:0}:{mainAxis:0,crossAxis:0,...x};if(s){const t="y"===h?"height":"width",e=r.reference[h]-r.floating[t]+v.mainAxis,n=r.reference[h]+r.reference[t]-v.mainAxis;yn&&(y=n)}if(u){var b,A;const t="y"===h?"width":"height",e=["top","left"].includes(c(o)),n=r.reference[p]-r.floating[t]+(e&&(null==(b=a.offset)?void 0:b[p])||0)+(e?0:v.crossAxis),i=r.reference[p]+r.reference[t]+(e?0:(null==(A=a.offset)?void 0:A[p])||0)-(e?v.crossAxis:0);wi&&(w=i)}return{[h]:y,[p]:w}}}},t.offset=function(t){return void 0===t&&(t=0),{name:"offset",options:t,async fn(e){var n,i;const{x:o,y:r,placement:a,middlewareData:l}=e,s=await async function(t,e){const{placement:n,platform:i,elements:o}=t,r=await(null==i.isRTL?void 0:i.isRTL(o.floating)),a=c(n),l=u(n),s="y"===g(n),m=["left","top"].includes(a)?-1:1,d=r&&s?-1:1,p=f(e,t);let{mainAxis:h,crossAxis:y,alignmentAxis:w}="number"==typeof p?{mainAxis:p,crossAxis:0,alignmentAxis:null}:{mainAxis:p.mainAxis||0,crossAxis:p.crossAxis||0,alignmentAxis:p.alignmentAxis};return l&&"number"==typeof w&&(y="end"===l?-1*w:w),s?{x:y*d,y:h*m}:{x:h*m,y:y*d}}(e,t);return a===(null==(n=l.offset)?void 0:n.placement)&&null!=(i=l.arrow)&&i.alignmentOffset?{}:{x:o+s.x,y:r+s.y,data:{...s,placement:a}}}}},t.rectToClientRect=v,t.shift=function(t){return void 0===t&&(t={}),{name:"shift",options:t,async fn(e){const{x:n,y:i,placement:o}=e,{mainAxis:r=!0,crossAxis:a=!1,limiter:l={fn:t=>{let{x:e,y:n}=t;return{x:e,y:n}}},...u}=f(t,e),d={x:n,y:i},p=await A(e,u),h=g(c(o)),y=m(h);let w=d[y],x=d[h];if(r){const t="y"===y?"bottom":"right";w=s(w+p["y"===y?"top":"left"],w,w-p[t])}if(a){const t="y"===h?"bottom":"right";x=s(x+p["y"===h?"top":"left"],x,x-p[t])}const v=l.fn({...e,[y]:w,[h]:x});return{...v,data:{x:v.x-n,y:v.y-i,enabled:{[y]:r,[h]:a}}}}}},t.size=function(t){return void 0===t&&(t={}),{name:"size",options:t,async fn(e){var n,i;const{placement:a,rects:l,platform:s,elements:m}=e,{apply:d=(()=>{}),...p}=f(t,e),h=await A(e,p),y=c(a),w=u(a),x="y"===g(a),{width:v,height:b}=l.floating;let R,P;"top"===y||"bottom"===y?(R=y,P=w===(await(null==s.isRTL?void 0:s.isRTL(m.floating))?"start":"end")?"left":"right"):(P=y,R="end"===w?"top":"bottom");const D=b-h.top-h.bottom,T=v-h.left-h.right,O=o(b-h[R],D),E=o(v-h[P],T),L=!e.middlewareData.shift;let k=O,C=E;if(null!=(n=e.middlewareData.shift)&&n.enabled.x&&(C=T),null!=(i=e.middlewareData.shift)&&i.enabled.y&&(k=D),L&&!w){const t=r(h.left,0),e=r(h.right,0),n=r(h.top,0),i=r(h.bottom,0);x?C=v-2*(0!==t||0!==e?t+e:r(h.left,h.right)):k=b-2*(0!==n||0!==i?n+i:r(h.top,h.bottom))}await d({...e,availableWidth:C,availableHeight:k});const B=await s.getDimensions(m.floating);return v!==B.width||b!==B.height?{reset:{rects:!0}}:{}}}}})); diff --git a/static/js/vendor/floating-ui.dom.umd.min.js b/static/js/vendor/floating-ui.dom.umd.min.js deleted file mode 100644 index 237acd2394..0000000000 --- a/static/js/vendor/floating-ui.dom.umd.min.js +++ /dev/null @@ -1 +0,0 @@ -!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports,require("@floating-ui/core")):"function"==typeof define&&define.amd?define(["exports","@floating-ui/core"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).FloatingUIDOM={},t.FloatingUICore)}(this,(function(t,e){"use strict";const n=Math.min,o=Math.max,i=Math.round,r=Math.floor,c=t=>({x:t,y:t});function l(){return"undefined"!=typeof window}function s(t){return a(t)?(t.nodeName||"").toLowerCase():"#document"}function f(t){var e;return(null==t||null==(e=t.ownerDocument)?void 0:e.defaultView)||window}function u(t){var e;return null==(e=(a(t)?t.ownerDocument:t.document)||window.document)?void 0:e.documentElement}function a(t){return!!l()&&(t instanceof Node||t instanceof f(t).Node)}function d(t){return!!l()&&(t instanceof Element||t instanceof f(t).Element)}function h(t){return!!l()&&(t instanceof HTMLElement||t instanceof f(t).HTMLElement)}function p(t){return!(!l()||"undefined"==typeof ShadowRoot)&&(t instanceof ShadowRoot||t instanceof f(t).ShadowRoot)}function g(t){const{overflow:e,overflowX:n,overflowY:o,display:i}=b(t);return/auto|scroll|overlay|hidden|clip/.test(e+o+n)&&!["inline","contents"].includes(i)}function m(t){return["table","td","th"].includes(s(t))}function y(t){return[":popover-open",":modal"].some((e=>{try{return t.matches(e)}catch(t){return!1}}))}function w(t){const e=x(),n=d(t)?b(t):t;return["transform","translate","scale","rotate","perspective"].some((t=>!!n[t]&&"none"!==n[t]))||!!n.containerType&&"normal"!==n.containerType||!e&&!!n.backdropFilter&&"none"!==n.backdropFilter||!e&&!!n.filter&&"none"!==n.filter||["transform","translate","scale","rotate","perspective","filter"].some((t=>(n.willChange||"").includes(t)))||["paint","layout","strict","content"].some((t=>(n.contain||"").includes(t)))}function x(){return!("undefined"==typeof CSS||!CSS.supports)&&CSS.supports("-webkit-backdrop-filter","none")}function v(t){return["html","body","#document"].includes(s(t))}function b(t){return f(t).getComputedStyle(t)}function T(t){return d(t)?{scrollLeft:t.scrollLeft,scrollTop:t.scrollTop}:{scrollLeft:t.scrollX,scrollTop:t.scrollY}}function L(t){if("html"===s(t))return t;const e=t.assignedSlot||t.parentNode||p(t)&&t.host||u(t);return p(e)?e.host:e}function R(t){const e=L(t);return v(e)?t.ownerDocument?t.ownerDocument.body:t.body:h(e)&&g(e)?e:R(e)}function C(t,e,n){var o;void 0===e&&(e=[]),void 0===n&&(n=!0);const i=R(t),r=i===(null==(o=t.ownerDocument)?void 0:o.body),c=f(i);if(r){const t=E(c);return e.concat(c,c.visualViewport||[],g(i)?i:[],t&&n?C(t):[])}return e.concat(i,C(i,[],n))}function E(t){return t.parent&&Object.getPrototypeOf(t.parent)?t.frameElement:null}function S(t){const e=b(t);let n=parseFloat(e.width)||0,o=parseFloat(e.height)||0;const r=h(t),c=r?t.offsetWidth:n,l=r?t.offsetHeight:o,s=i(n)!==c||i(o)!==l;return s&&(n=c,o=l),{width:n,height:o,$:s}}function F(t){return d(t)?t:t.contextElement}function O(t){const e=F(t);if(!h(e))return c(1);const n=e.getBoundingClientRect(),{width:o,height:r,$:l}=S(e);let s=(l?i(n.width):n.width)/o,f=(l?i(n.height):n.height)/r;return s&&Number.isFinite(s)||(s=1),f&&Number.isFinite(f)||(f=1),{x:s,y:f}}const D=c(0);function H(t){const e=f(t);return x()&&e.visualViewport?{x:e.visualViewport.offsetLeft,y:e.visualViewport.offsetTop}:D}function P(t,n,o,i){void 0===n&&(n=!1),void 0===o&&(o=!1);const r=t.getBoundingClientRect(),l=F(t);let s=c(1);n&&(i?d(i)&&(s=O(i)):s=O(t));const u=function(t,e,n){return void 0===e&&(e=!1),!(!n||e&&n!==f(t))&&e}(l,o,i)?H(l):c(0);let a=(r.left+u.x)/s.x,h=(r.top+u.y)/s.y,p=r.width/s.x,g=r.height/s.y;if(l){const t=f(l),e=i&&d(i)?f(i):i;let n=t,o=E(n);for(;o&&i&&e!==n;){const t=O(o),e=o.getBoundingClientRect(),i=b(o),r=e.left+(o.clientLeft+parseFloat(i.paddingLeft))*t.x,c=e.top+(o.clientTop+parseFloat(i.paddingTop))*t.y;a*=t.x,h*=t.y,p*=t.x,g*=t.y,a+=r,h+=c,n=f(o),o=E(n)}}return e.rectToClientRect({width:p,height:g,x:a,y:h})}function W(t,e){const n=T(t).scrollLeft;return e?e.left+n:P(u(t)).left+n}function M(t,e,n){void 0===n&&(n=!1);const o=t.getBoundingClientRect();return{x:o.left+e.scrollLeft-(n?0:W(t,o)),y:o.top+e.scrollTop}}function z(t,n,i){let r;if("viewport"===n)r=function(t,e){const n=f(t),o=u(t),i=n.visualViewport;let r=o.clientWidth,c=o.clientHeight,l=0,s=0;if(i){r=i.width,c=i.height;const t=x();(!t||t&&"fixed"===e)&&(l=i.offsetLeft,s=i.offsetTop)}return{width:r,height:c,x:l,y:s}}(t,i);else if("document"===n)r=function(t){const e=u(t),n=T(t),i=t.ownerDocument.body,r=o(e.scrollWidth,e.clientWidth,i.scrollWidth,i.clientWidth),c=o(e.scrollHeight,e.clientHeight,i.scrollHeight,i.clientHeight);let l=-n.scrollLeft+W(t);const s=-n.scrollTop;return"rtl"===b(i).direction&&(l+=o(e.clientWidth,i.clientWidth)-r),{width:r,height:c,x:l,y:s}}(u(t));else if(d(n))r=function(t,e){const n=P(t,!0,"fixed"===e),o=n.top+t.clientTop,i=n.left+t.clientLeft,r=h(t)?O(t):c(1);return{width:t.clientWidth*r.x,height:t.clientHeight*r.y,x:i*r.x,y:o*r.y}}(n,i);else{const e=H(t);r={x:n.x-e.x,y:n.y-e.y,width:n.width,height:n.height}}return e.rectToClientRect(r)}function A(t,e){const n=L(t);return!(n===e||!d(n)||v(n))&&("fixed"===b(n).position||A(n,e))}function B(t,e,n){const o=h(e),i=u(e),r="fixed"===n,l=P(t,!0,r,e);let f={scrollLeft:0,scrollTop:0};const a=c(0);if(o||!o&&!r)if(("body"!==s(e)||g(i))&&(f=T(e)),o){const t=P(e,!0,r,e);a.x=t.x+e.clientLeft,a.y=t.y+e.clientTop}else i&&(a.x=W(i));const d=!i||o||r?c(0):M(i,f);return{x:l.left+f.scrollLeft-a.x-d.x,y:l.top+f.scrollTop-a.y-d.y,width:l.width,height:l.height}}function V(t){return"static"===b(t).position}function N(t,e){if(!h(t)||"fixed"===b(t).position)return null;if(e)return e(t);let n=t.offsetParent;return u(t)===n&&(n=n.ownerDocument.body),n}function I(t,e){const n=f(t);if(y(t))return n;if(!h(t)){let e=L(t);for(;e&&!v(e);){if(d(e)&&!V(e))return e;e=L(e)}return n}let o=N(t,e);for(;o&&m(o)&&V(o);)o=N(o,e);return o&&v(o)&&V(o)&&!w(o)?n:o||function(t){let e=L(t);for(;h(e)&&!v(e);){if(w(e))return e;if(y(e))return null;e=L(e)}return null}(t)||n}const k={convertOffsetParentRelativeRectToViewportRelativeRect:function(t){let{elements:e,rect:n,offsetParent:o,strategy:i}=t;const r="fixed"===i,l=u(o),f=!!e&&y(e.floating);if(o===l||f&&r)return n;let a={scrollLeft:0,scrollTop:0},d=c(1);const p=c(0),m=h(o);if((m||!m&&!r)&&(("body"!==s(o)||g(l))&&(a=T(o)),h(o))){const t=P(o);d=O(o),p.x=t.x+o.clientLeft,p.y=t.y+o.clientTop}const w=!l||m||r?c(0):M(l,a,!0);return{width:n.width*d.x,height:n.height*d.y,x:n.x*d.x-a.scrollLeft*d.x+p.x+w.x,y:n.y*d.y-a.scrollTop*d.y+p.y+w.y}},getDocumentElement:u,getClippingRect:function(t){let{element:e,boundary:i,rootBoundary:r,strategy:c}=t;const l=[..."clippingAncestors"===i?y(e)?[]:function(t,e){const n=e.get(t);if(n)return n;let o=C(t,[],!1).filter((t=>d(t)&&"body"!==s(t))),i=null;const r="fixed"===b(t).position;let c=r?L(t):t;for(;d(c)&&!v(c);){const e=b(c),n=w(c);n||"fixed"!==e.position||(i=null),(r?!n&&!i:!n&&"static"===e.position&&i&&["absolute","fixed"].includes(i.position)||g(c)&&!n&&A(t,c))?o=o.filter((t=>t!==c)):i=e,c=L(c)}return e.set(t,o),o}(e,this._c):[].concat(i),r],f=l[0],u=l.reduce(((t,i)=>{const r=z(e,i,c);return t.top=o(r.top,t.top),t.right=n(r.right,t.right),t.bottom=n(r.bottom,t.bottom),t.left=o(r.left,t.left),t}),z(e,f,c));return{width:u.right-u.left,height:u.bottom-u.top,x:u.left,y:u.top}},getOffsetParent:I,getElementRects:async function(t){const e=this.getOffsetParent||I,n=this.getDimensions,o=await n(t.floating);return{reference:B(t.reference,await e(t.floating),t.strategy),floating:{x:0,y:0,width:o.width,height:o.height}}},getClientRects:function(t){return Array.from(t.getClientRects())},getDimensions:function(t){const{width:e,height:n}=S(t);return{width:e,height:n}},getScale:O,isElement:d,isRTL:function(t){return"rtl"===b(t).direction}};function q(t,e){return t.x===e.x&&t.y===e.y&&t.width===e.width&&t.height===e.height}const U=e.detectOverflow,j=e.offset,X=e.autoPlacement,Y=e.shift,$=e.flip,_=e.size,G=e.hide,J=e.arrow,K=e.inline,Q=e.limitShift;t.arrow=J,t.autoPlacement=X,t.autoUpdate=function(t,e,i,c){void 0===c&&(c={});const{ancestorScroll:l=!0,ancestorResize:s=!0,elementResize:f="function"==typeof ResizeObserver,layoutShift:a="function"==typeof IntersectionObserver,animationFrame:d=!1}=c,h=F(t),p=l||s?[...h?C(h):[],...C(e)]:[];p.forEach((t=>{l&&t.addEventListener("scroll",i,{passive:!0}),s&&t.addEventListener("resize",i)}));const g=h&&a?function(t,e){let i,c=null;const l=u(t);function s(){var t;clearTimeout(i),null==(t=c)||t.disconnect(),c=null}return function f(u,a){void 0===u&&(u=!1),void 0===a&&(a=1),s();const d=t.getBoundingClientRect(),{left:h,top:p,width:g,height:m}=d;if(u||e(),!g||!m)return;const y={rootMargin:-r(p)+"px "+-r(l.clientWidth-(h+g))+"px "+-r(l.clientHeight-(p+m))+"px "+-r(h)+"px",threshold:o(0,n(1,a))||1};let w=!0;function x(e){const n=e[0].intersectionRatio;if(n!==a){if(!w)return f();n?f(!1,n):i=setTimeout((()=>{f(!1,1e-7)}),1e3)}1!==n||q(d,t.getBoundingClientRect())||f(),w=!1}try{c=new IntersectionObserver(x,{...y,root:l.ownerDocument})}catch(t){c=new IntersectionObserver(x,y)}c.observe(t)}(!0),s}(h,i):null;let m,y=-1,w=null;f&&(w=new ResizeObserver((t=>{let[n]=t;n&&n.target===h&&w&&(w.unobserve(e),cancelAnimationFrame(y),y=requestAnimationFrame((()=>{var t;null==(t=w)||t.observe(e)}))),i()})),h&&!d&&w.observe(h),w.observe(e));let x=d?P(t):null;return d&&function e(){const n=P(t);x&&!q(x,n)&&i();x=n,m=requestAnimationFrame(e)}(),i(),()=>{var t;p.forEach((t=>{l&&t.removeEventListener("scroll",i),s&&t.removeEventListener("resize",i)})),null==g||g(),null==(t=w)||t.disconnect(),w=null,d&&cancelAnimationFrame(m)}},t.computePosition=(t,n,o)=>{const i=new Map,r={platform:k,...o},c={...r.platform,_c:i};return e.computePosition(t,n,{...r,platform:c})},t.detectOverflow=U,t.flip=$,t.getOverflowAncestors=C,t.hide=G,t.inline=K,t.limitShift=Q,t.offset=j,t.platform=k,t.shift=Y,t.size=_})); diff --git a/static/js/vendor/jquery-3.7.1.min.js b/static/js/vendor/jquery-3.7.1.min.js deleted file mode 100644 index 7f37b5d991..0000000000 --- a/static/js/vendor/jquery-3.7.1.min.js +++ /dev/null @@ -1,2 +0,0 @@ -/*! jQuery v3.7.1 | (c) OpenJS Foundation and other contributors | jquery.org/license */ -!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(ie,e){"use strict";var oe=[],r=Object.getPrototypeOf,ae=oe.slice,g=oe.flat?function(e){return oe.flat.call(e)}:function(e){return oe.concat.apply([],e)},s=oe.push,se=oe.indexOf,n={},i=n.toString,ue=n.hasOwnProperty,o=ue.toString,a=o.call(Object),le={},v=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},y=function(e){return null!=e&&e===e.window},C=ie.document,u={type:!0,src:!0,nonce:!0,noModule:!0};function m(e,t,n){var r,i,o=(n=n||C).createElement("script");if(o.text=e,t)for(r in u)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function x(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[i.call(e)]||"object":typeof e}var t="3.7.1",l=/HTML$/i,ce=function(e,t){return new ce.fn.init(e,t)};function c(e){var t=!!e&&"length"in e&&e.length,n=x(e);return!v(e)&&!y(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+ge+")"+ge+"*"),x=new RegExp(ge+"|>"),j=new RegExp(g),A=new RegExp("^"+t+"$"),D={ID:new RegExp("^#("+t+")"),CLASS:new RegExp("^\\.("+t+")"),TAG:new RegExp("^("+t+"|[*])"),ATTR:new RegExp("^"+p),PSEUDO:new RegExp("^"+g),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+ge+"*(even|odd|(([+-]|)(\\d*)n|)"+ge+"*(?:([+-]|)"+ge+"*(\\d+)|))"+ge+"*\\)|)","i"),bool:new RegExp("^(?:"+f+")$","i"),needsContext:new RegExp("^"+ge+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+ge+"*((?:-\\d)?\\d*)"+ge+"*\\)|)(?=[^-]|$)","i")},N=/^(?:input|select|textarea|button)$/i,q=/^h\d$/i,L=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,H=/[+~]/,O=new RegExp("\\\\[\\da-fA-F]{1,6}"+ge+"?|\\\\([^\\r\\n\\f])","g"),P=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},M=function(){V()},R=J(function(e){return!0===e.disabled&&fe(e,"fieldset")},{dir:"parentNode",next:"legend"});try{k.apply(oe=ae.call(ye.childNodes),ye.childNodes),oe[ye.childNodes.length].nodeType}catch(e){k={apply:function(e,t){me.apply(e,ae.call(t))},call:function(e){me.apply(e,ae.call(arguments,1))}}}function I(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(V(e),e=e||T,C)){if(11!==p&&(u=L.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return k.call(n,a),n}else if(f&&(a=f.getElementById(i))&&I.contains(e,a)&&a.id===i)return k.call(n,a),n}else{if(u[2])return k.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&e.getElementsByClassName)return k.apply(n,e.getElementsByClassName(i)),n}if(!(h[t+" "]||d&&d.test(t))){if(c=t,f=e,1===p&&(x.test(t)||m.test(t))){(f=H.test(t)&&U(e.parentNode)||e)==e&&le.scope||((s=e.getAttribute("id"))?s=ce.escapeSelector(s):e.setAttribute("id",s=S)),o=(l=Y(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+Q(l[o]);c=l.join(",")}try{return k.apply(n,f.querySelectorAll(c)),n}catch(e){h(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return re(t.replace(ve,"$1"),e,n,r)}function W(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function F(e){return e[S]=!0,e}function $(e){var t=T.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function B(t){return function(e){return fe(e,"input")&&e.type===t}}function _(t){return function(e){return(fe(e,"input")||fe(e,"button"))&&e.type===t}}function z(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&R(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function X(a){return F(function(o){return o=+o,F(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function U(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}function V(e){var t,n=e?e.ownerDocument||e:ye;return n!=T&&9===n.nodeType&&n.documentElement&&(r=(T=n).documentElement,C=!ce.isXMLDoc(T),i=r.matches||r.webkitMatchesSelector||r.msMatchesSelector,r.msMatchesSelector&&ye!=T&&(t=T.defaultView)&&t.top!==t&&t.addEventListener("unload",M),le.getById=$(function(e){return r.appendChild(e).id=ce.expando,!T.getElementsByName||!T.getElementsByName(ce.expando).length}),le.disconnectedMatch=$(function(e){return i.call(e,"*")}),le.scope=$(function(){return T.querySelectorAll(":scope")}),le.cssHas=$(function(){try{return T.querySelector(":has(*,:jqfake)"),!1}catch(e){return!0}}),le.getById?(b.filter.ID=function(e){var t=e.replace(O,P);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&C){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(O,P);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&C){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):t.querySelectorAll(e)},b.find.CLASS=function(e,t){if("undefined"!=typeof t.getElementsByClassName&&C)return t.getElementsByClassName(e)},d=[],$(function(e){var t;r.appendChild(e).innerHTML="",e.querySelectorAll("[selected]").length||d.push("\\["+ge+"*(?:value|"+f+")"),e.querySelectorAll("[id~="+S+"-]").length||d.push("~="),e.querySelectorAll("a#"+S+"+*").length||d.push(".#.+[+~]"),e.querySelectorAll(":checked").length||d.push(":checked"),(t=T.createElement("input")).setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),r.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&d.push(":enabled",":disabled"),(t=T.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||d.push("\\["+ge+"*name"+ge+"*="+ge+"*(?:''|\"\")")}),le.cssHas||d.push(":has"),d=d.length&&new RegExp(d.join("|")),l=function(e,t){if(e===t)return a=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!le.sortDetached&&t.compareDocumentPosition(e)===n?e===T||e.ownerDocument==ye&&I.contains(ye,e)?-1:t===T||t.ownerDocument==ye&&I.contains(ye,t)?1:o?se.call(o,e)-se.call(o,t):0:4&n?-1:1)}),T}for(e in I.matches=function(e,t){return I(e,null,null,t)},I.matchesSelector=function(e,t){if(V(e),C&&!h[t+" "]&&(!d||!d.test(t)))try{var n=i.call(e,t);if(n||le.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){h(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(O,P),e[3]=(e[3]||e[4]||e[5]||"").replace(O,P),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||I.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&I.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return D.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&j.test(n)&&(t=Y(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(O,P).toLowerCase();return"*"===e?function(){return!0}:function(e){return fe(e,t)}},CLASS:function(e){var t=s[e+" "];return t||(t=new RegExp("(^|"+ge+")"+e+"("+ge+"|$)"))&&s(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=I.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function T(e,n,r){return v(n)?ce.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?ce.grep(e,function(e){return e===n!==r}):"string"!=typeof n?ce.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(ce.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||k,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:S.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof ce?t[0]:t,ce.merge(this,ce.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:C,!0)),w.test(r[1])&&ce.isPlainObject(t))for(r in t)v(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=C.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):v(e)?void 0!==n.ready?n.ready(e):e(ce):ce.makeArray(e,this)}).prototype=ce.fn,k=ce(C);var E=/^(?:parents|prev(?:Until|All))/,j={children:!0,contents:!0,next:!0,prev:!0};function A(e,t){while((e=e[t])&&1!==e.nodeType);return e}ce.fn.extend({has:function(e){var t=ce(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,Ce=/^$|^module$|\/(?:java|ecma)script/i;xe=C.createDocumentFragment().appendChild(C.createElement("div")),(be=C.createElement("input")).setAttribute("type","radio"),be.setAttribute("checked","checked"),be.setAttribute("name","t"),xe.appendChild(be),le.checkClone=xe.cloneNode(!0).cloneNode(!0).lastChild.checked,xe.innerHTML="",le.noCloneChecked=!!xe.cloneNode(!0).lastChild.defaultValue,xe.innerHTML="",le.option=!!xe.lastChild;var ke={thead:[1,"","
    "],col:[2,"","
    "],tr:[2,"","
    "],td:[3,"","
    "],_default:[0,"",""]};function Se(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&fe(e,t)?ce.merge([e],n):n}function Ee(e,t){for(var n=0,r=e.length;n",""]);var je=/<|&#?\w+;/;function Ae(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function Re(e,t){return fe(e,"table")&&fe(11!==t.nodeType?t:t.firstChild,"tr")&&ce(e).children("tbody")[0]||e}function Ie(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function We(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Fe(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(_.hasData(e)&&(s=_.get(e).events))for(i in _.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),C.head.appendChild(r[0])},abort:function(){i&&i()}}});var Jt,Kt=[],Zt=/(=)\?(?=&|$)|\?\?/;ce.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Kt.pop()||ce.expando+"_"+jt.guid++;return this[e]=!0,e}}),ce.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Zt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Zt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=v(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Zt,"$1"+r):!1!==e.jsonp&&(e.url+=(At.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||ce.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=ie[r],ie[r]=function(){o=arguments},n.always(function(){void 0===i?ce(ie).removeProp(r):ie[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Kt.push(r)),o&&v(i)&&i(o[0]),o=i=void 0}),"script"}),le.createHTMLDocument=((Jt=C.implementation.createHTMLDocument("").body).innerHTML="
    ",2===Jt.childNodes.length),ce.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(le.createHTMLDocument?((r=(t=C.implementation.createHTMLDocument("")).createElement("base")).href=C.location.href,t.head.appendChild(r)):t=C),o=!n&&[],(i=w.exec(e))?[t.createElement(i[1])]:(i=Ae([e],t,o),o&&o.length&&ce(o).remove(),ce.merge([],i.childNodes)));var r,i,o},ce.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(ce.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},ce.expr.pseudos.animated=function(t){return ce.grep(ce.timers,function(e){return t===e.elem}).length},ce.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=ce.css(e,"position"),c=ce(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=ce.css(e,"top"),u=ce.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),v(t)&&(t=t.call(e,n,ce.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},ce.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){ce.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===ce.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===ce.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=ce(e).offset()).top+=ce.css(e,"borderTopWidth",!0),i.left+=ce.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-ce.css(r,"marginTop",!0),left:t.left-i.left-ce.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===ce.css(e,"position"))e=e.offsetParent;return e||J})}}),ce.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;ce.fn[t]=function(e){return M(this,function(e,t,n){var r;if(y(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),ce.each(["top","left"],function(e,n){ce.cssHooks[n]=Ye(le.pixelPosition,function(e,t){if(t)return t=Ge(e,n),_e.test(t)?ce(e).position()[n]+"px":t})}),ce.each({Height:"height",Width:"width"},function(a,s){ce.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){ce.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return M(this,function(e,t,n){var r;return y(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?ce.css(e,t,i):ce.style(e,t,n,i)},s,n?e:void 0,n)}})}),ce.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){ce.fn[t]=function(e){return this.on(t,e)}}),ce.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.on("mouseenter",e).on("mouseleave",t||e)}}),ce.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){ce.fn[n]=function(e,t){return 0>> 0, - i; - - for (i = 0; i < len; i++) { - if (i in t && fun.call(this, t[i], i, t)) { - return true; - } - } - - return false; - }; - } - - function isValid(m) { - var flags = null, - parsedParts = false, - isNowValid = m._d && !isNaN(m._d.getTime()); - if (isNowValid) { - flags = getParsingFlags(m); - parsedParts = some.call(flags.parsedDateParts, function(i) { - return i != null; - }); - isNowValid = - flags.overflow < 0 && - !flags.empty && - !flags.invalidEra && - !flags.invalidMonth && - !flags.invalidWeekday && - !flags.weekdayMismatch && - !flags.nullInput && - !flags.invalidFormat && - !flags.userInvalidated && - (!flags.meridiem || (flags.meridiem && parsedParts)); - if (m._strict) { - isNowValid = - isNowValid && - flags.charsLeftOver === 0 && - flags.unusedTokens.length === 0 && - flags.bigHour === undefined; - } - } - if (Object.isFrozen == null || !Object.isFrozen(m)) { - m._isValid = isNowValid; - } else { - return isNowValid; - } - return m._isValid; - } - - function createInvalid(flags) { - var m = createUTC(NaN); - if (flags != null) { - extend(getParsingFlags(m), flags); - } else { - getParsingFlags(m).userInvalidated = true; - } - - return m; - } - - // Plugins that add properties should also add the key here (null value), - // so we can properly clone ourselves. - var momentProperties = (hooks.momentProperties = []), - updateInProgress = false; - - function copyConfig(to, from) { - var i, - prop, - val, - momentPropertiesLen = momentProperties.length; - - if (!isUndefined(from._isAMomentObject)) { - to._isAMomentObject = from._isAMomentObject; - } - if (!isUndefined(from._i)) { - to._i = from._i; - } - if (!isUndefined(from._f)) { - to._f = from._f; - } - if (!isUndefined(from._l)) { - to._l = from._l; - } - if (!isUndefined(from._strict)) { - to._strict = from._strict; - } - if (!isUndefined(from._tzm)) { - to._tzm = from._tzm; - } - if (!isUndefined(from._isUTC)) { - to._isUTC = from._isUTC; - } - if (!isUndefined(from._offset)) { - to._offset = from._offset; - } - if (!isUndefined(from._pf)) { - to._pf = getParsingFlags(from); - } - if (!isUndefined(from._locale)) { - to._locale = from._locale; - } - - if (momentPropertiesLen > 0) { - for (i = 0; i < momentPropertiesLen; i++) { - prop = momentProperties[i]; - val = from[prop]; - if (!isUndefined(val)) { - to[prop] = val; - } - } - } - - return to; - } - - // Moment prototype object - function Moment(config) { - copyConfig(this, config); - this._d = new Date(config._d != null ? config._d.getTime() : NaN); - if (!this.isValid()) { - this._d = new Date(NaN); - } - // Prevent infinite loop in case updateOffset creates new moment - // objects. - if (updateInProgress === false) { - updateInProgress = true; - hooks.updateOffset(this); - updateInProgress = false; - } - } - - function isMoment(obj) { - return ( - obj instanceof Moment || (obj != null && obj._isAMomentObject != null) - ); - } - - function warn(msg) { - if ( - hooks.suppressDeprecationWarnings === false && - typeof console !== 'undefined' && - console.warn - ) { - console.warn('Deprecation warning: ' + msg); - } - } - - function deprecate(msg, fn) { - var firstTime = true; - - return extend(function() { - if (hooks.deprecationHandler != null) { - hooks.deprecationHandler(null, msg); - } - if (firstTime) { - var args = [], - arg, - i, - key, - argLen = arguments.length; - for (i = 0; i < argLen; i++) { - arg = ''; - if (typeof arguments[i] === 'object') { - arg += '\n[' + i + '] '; - for (key in arguments[0]) { - if (hasOwnProp(arguments[0], key)) { - arg += key + ': ' + arguments[0][key] + ', '; - } - } - arg = arg.slice(0, -2); // Remove trailing comma and space - } else { - arg = arguments[i]; - } - args.push(arg); - } - warn( - msg + - '\nArguments: ' + - Array.prototype.slice.call(args).join('') + - '\n' + - new Error().stack - ); - firstTime = false; - } - return fn.apply(this, arguments); - }, fn); - } - - var deprecations = {}; - - function deprecateSimple(name, msg) { - if (hooks.deprecationHandler != null) { - hooks.deprecationHandler(name, msg); - } - if (!deprecations[name]) { - warn(msg); - deprecations[name] = true; - } - } - - hooks.suppressDeprecationWarnings = false; - hooks.deprecationHandler = null; - - function isFunction(input) { - return ( - (typeof Function !== 'undefined' && input instanceof Function) || - Object.prototype.toString.call(input) === '[object Function]' - ); - } - - function set(config) { - var prop, i; - for (i in config) { - if (hasOwnProp(config, i)) { - prop = config[i]; - if (isFunction(prop)) { - this[i] = prop; - } else { - this['_' + i] = prop; - } - } - } - this._config = config; - // Lenient ordinal parsing accepts just a number in addition to - // number + (possibly) stuff coming from _dayOfMonthOrdinalParse. - // TODO: Remove "ordinalParse" fallback in next major release. - this._dayOfMonthOrdinalParseLenient = new RegExp( - (this._dayOfMonthOrdinalParse.source || this._ordinalParse.source) + - '|' + - /\d{1,2}/.source - ); - } - - function mergeConfigs(parentConfig, childConfig) { - var res = extend({}, parentConfig), - prop; - for (prop in childConfig) { - if (hasOwnProp(childConfig, prop)) { - if (isObject(parentConfig[prop]) && isObject(childConfig[prop])) { - res[prop] = {}; - extend(res[prop], parentConfig[prop]); - extend(res[prop], childConfig[prop]); - } else if (childConfig[prop] != null) { - res[prop] = childConfig[prop]; - } else { - delete res[prop]; - } - } - } - for (prop in parentConfig) { - if ( - hasOwnProp(parentConfig, prop) && - !hasOwnProp(childConfig, prop) && - isObject(parentConfig[prop]) - ) { - // make sure changes to properties don't modify parent config - res[prop] = extend({}, res[prop]); - } - } - return res; - } - - function Locale(config) { - if (config != null) { - this.set(config); - } - } - - var keys; - - if (Object.keys) { - keys = Object.keys; - } else { - keys = function(obj) { - var i, - res = []; - for (i in obj) { - if (hasOwnProp(obj, i)) { - res.push(i); - } - } - return res; - }; - } - - var defaultCalendar = { - sameDay: '[Today at] LT', - nextDay: '[Tomorrow at] LT', - nextWeek: 'dddd [at] LT', - lastDay: '[Yesterday at] LT', - lastWeek: '[Last] dddd [at] LT', - sameElse: 'L', - }; - - function calendar(key, mom, now) { - var output = this._calendar[key] || this._calendar['sameElse']; - return isFunction(output) ? output.call(mom, now) : output; - } - - function zeroFill(number, targetLength, forceSign) { - var absNumber = '' + Math.abs(number), - zerosToFill = targetLength - absNumber.length, - sign = number >= 0; - return ( - (sign ? (forceSign ? '+' : '') : '-') + - Math.pow(10, Math.max(0, zerosToFill)).toString().substr(1) + - absNumber - ); - } - - var formattingTokens = - /(\[[^\[]*\])|(\\)?([Hh]mm(ss)?|Mo|MM?M?M?|Do|DDDo|DD?D?D?|ddd?d?|do?|w[o|w]?|W[o|W]?|Qo?|N{1,5}|YYYYYY|YYYYY|YYYY|YY|y{2,4}|yo?|gg(ggg?)?|GG(GGG?)?|e|E|a|A|hh?|HH?|kk?|mm?|ss?|S{1,9}|x|X|zz?|ZZ?|.)/g, - localFormattingTokens = /(\[[^\[]*\])|(\\)?(LTS|LT|LL?L?L?|l{1,4})/g, - formatFunctions = {}, - formatTokenFunctions = {}; - - // token: 'M' - // padded: ['MM', 2] - // ordinal: 'Mo' - // callback: function () { this.month() + 1 } - function addFormatToken(token, padded, ordinal, callback) { - var func = callback; - if (typeof callback === 'string') { - func = function() { - return this[callback](); - }; - } - if (token) { - formatTokenFunctions[token] = func; - } - if (padded) { - formatTokenFunctions[padded[0]] = function() { - return zeroFill(func.apply(this, arguments), padded[1], padded[2]); - }; - } - if (ordinal) { - formatTokenFunctions[ordinal] = function() { - return this.localeData().ordinal( - func.apply(this, arguments), - token - ); - }; - } - } - - function removeFormattingTokens(input) { - if (input.match(/\[[\s\S]/)) { - return input.replace(/^\[|\]$/g, ''); - } - return input.replace(/\\/g, ''); - } - - function makeFormatFunction(format) { - var array = format.match(formattingTokens), - i, - length; - - for (i = 0, length = array.length; i < length; i++) { - if (formatTokenFunctions[array[i]]) { - array[i] = formatTokenFunctions[array[i]]; - } else { - array[i] = removeFormattingTokens(array[i]); - } - } - - return function(mom) { - var output = '', - i; - for (i = 0; i < length; i++) { - output += isFunction(array[i]) ? - array[i].call(mom, format) : - array[i]; - } - return output; - }; - } - - // format date using native date object - function formatMoment(m, format) { - if (!m.isValid()) { - return m.localeData().invalidDate(); - } - - format = expandFormat(format, m.localeData()); - formatFunctions[format] = - formatFunctions[format] || makeFormatFunction(format); - - return formatFunctions[format](m); - } - - function expandFormat(format, locale) { - var i = 5; - - function replaceLongDateFormatTokens(input) { - return locale.longDateFormat(input) || input; - } - - localFormattingTokens.lastIndex = 0; - while (i >= 0 && localFormattingTokens.test(format)) { - format = format.replace( - localFormattingTokens, - replaceLongDateFormatTokens - ); - localFormattingTokens.lastIndex = 0; - i -= 1; - } - - return format; - } - - var defaultLongDateFormat = { - LTS: 'h:mm:ss A', - LT: 'h:mm A', - L: 'MM/DD/YYYY', - LL: 'MMMM D, YYYY', - LLL: 'MMMM D, YYYY h:mm A', - LLLL: 'dddd, MMMM D, YYYY h:mm A', - }; - - function longDateFormat(key) { - var format = this._longDateFormat[key], - formatUpper = this._longDateFormat[key.toUpperCase()]; - - if (format || !formatUpper) { - return format; - } - - this._longDateFormat[key] = formatUpper - .match(formattingTokens) - .map(function(tok) { - if ( - tok === 'MMMM' || - tok === 'MM' || - tok === 'DD' || - tok === 'dddd' - ) { - return tok.slice(1); - } - return tok; - }) - .join(''); - - return this._longDateFormat[key]; - } - - var defaultInvalidDate = 'Invalid date'; - - function invalidDate() { - return this._invalidDate; - } - - var defaultOrdinal = '%d', - defaultDayOfMonthOrdinalParse = /\d{1,2}/; - - function ordinal(number) { - return this._ordinal.replace('%d', number); - } - - var defaultRelativeTime = { - future: 'in %s', - past: '%s ago', - s: 'a few seconds', - ss: '%d seconds', - m: 'a minute', - mm: '%d minutes', - h: 'an hour', - hh: '%d hours', - d: 'a day', - dd: '%d days', - w: 'a week', - ww: '%d weeks', - M: 'a month', - MM: '%d months', - y: 'a year', - yy: '%d years', - }; - - function relativeTime(number, withoutSuffix, string, isFuture) { - var output = this._relativeTime[string]; - return isFunction(output) ? - output(number, withoutSuffix, string, isFuture) : - output.replace(/%d/i, number); - } - - function pastFuture(diff, output) { - var format = this._relativeTime[diff > 0 ? 'future' : 'past']; - return isFunction(format) ? format(output) : format.replace(/%s/i, output); - } - - var aliases = { - D: 'date', - dates: 'date', - date: 'date', - d: 'day', - days: 'day', - day: 'day', - e: 'weekday', - weekdays: 'weekday', - weekday: 'weekday', - E: 'isoWeekday', - isoweekdays: 'isoWeekday', - isoweekday: 'isoWeekday', - DDD: 'dayOfYear', - dayofyears: 'dayOfYear', - dayofyear: 'dayOfYear', - h: 'hour', - hours: 'hour', - hour: 'hour', - ms: 'millisecond', - milliseconds: 'millisecond', - millisecond: 'millisecond', - m: 'minute', - minutes: 'minute', - minute: 'minute', - M: 'month', - months: 'month', - month: 'month', - Q: 'quarter', - quarters: 'quarter', - quarter: 'quarter', - s: 'second', - seconds: 'second', - second: 'second', - gg: 'weekYear', - weekyears: 'weekYear', - weekyear: 'weekYear', - GG: 'isoWeekYear', - isoweekyears: 'isoWeekYear', - isoweekyear: 'isoWeekYear', - w: 'week', - weeks: 'week', - week: 'week', - W: 'isoWeek', - isoweeks: 'isoWeek', - isoweek: 'isoWeek', - y: 'year', - years: 'year', - year: 'year', - }; - - function normalizeUnits(units) { - return typeof units === 'string' ? - aliases[units] || aliases[units.toLowerCase()] : - undefined; - } - - function normalizeObjectUnits(inputObject) { - var normalizedInput = {}, - normalizedProp, - prop; - - for (prop in inputObject) { - if (hasOwnProp(inputObject, prop)) { - normalizedProp = normalizeUnits(prop); - if (normalizedProp) { - normalizedInput[normalizedProp] = inputObject[prop]; - } - } - } - - return normalizedInput; - } - - var priorities = { - date: 9, - day: 11, - weekday: 11, - isoWeekday: 11, - dayOfYear: 4, - hour: 13, - millisecond: 16, - minute: 14, - month: 8, - quarter: 7, - second: 15, - weekYear: 1, - isoWeekYear: 1, - week: 5, - isoWeek: 5, - year: 1, - }; - - function getPrioritizedUnits(unitsObj) { - var units = [], - u; - for (u in unitsObj) { - if (hasOwnProp(unitsObj, u)) { - units.push({ - unit: u, - priority: priorities[u] - }); - } - } - units.sort(function(a, b) { - return a.priority - b.priority; - }); - return units; - } - - var match1 = /\d/, // 0 - 9 - match2 = /\d\d/, // 00 - 99 - match3 = /\d{3}/, // 000 - 999 - match4 = /\d{4}/, // 0000 - 9999 - match6 = /[+-]?\d{6}/, // -999999 - 999999 - match1to2 = /\d\d?/, // 0 - 99 - match3to4 = /\d\d\d\d?/, // 999 - 9999 - match5to6 = /\d\d\d\d\d\d?/, // 99999 - 999999 - match1to3 = /\d{1,3}/, // 0 - 999 - match1to4 = /\d{1,4}/, // 0 - 9999 - match1to6 = /[+-]?\d{1,6}/, // -999999 - 999999 - matchUnsigned = /\d+/, // 0 - inf - matchSigned = /[+-]?\d+/, // -inf - inf - matchOffset = /Z|[+-]\d\d:?\d\d/gi, // +00:00 -00:00 +0000 -0000 or Z - matchShortOffset = /Z|[+-]\d\d(?::?\d\d)?/gi, // +00 -00 +00:00 -00:00 +0000 -0000 or Z - matchTimestamp = /[+-]?\d+(\.\d{1,3})?/, // 123456789 123456789.123 - // any word (or two) characters or numbers including two/three word month in arabic. - // includes scottish gaelic two word and hyphenated months - matchWord = - /[0-9]{0,256}['a-z\u00A0-\u05FF\u0700-\uD7FF\uF900-\uFDCF\uFDF0-\uFF07\uFF10-\uFFEF]{1,256}|[\u0600-\u06FF\/]{1,256}(\s*?[\u0600-\u06FF]{1,256}){1,2}/i, - match1to2NoLeadingZero = /^[1-9]\d?/, // 1-99 - match1to2HasZero = /^([1-9]\d|\d)/, // 0-99 - regexes; - - regexes = {}; - - function addRegexToken(token, regex, strictRegex) { - regexes[token] = isFunction(regex) ? - regex : - function(isStrict, localeData) { - return isStrict && strictRegex ? strictRegex : regex; - }; - } - - function getParseRegexForToken(token, config) { - if (!hasOwnProp(regexes, token)) { - return new RegExp(unescapeFormat(token)); - } - - return regexes[token](config._strict, config._locale); - } - - // Code from http://stackoverflow.com/questions/3561493/is-there-a-regexp-escape-function-in-javascript - function unescapeFormat(s) { - return regexEscape( - s - .replace('\\', '') - .replace( - /\\(\[)|\\(\])|\[([^\]\[]*)\]|\\(.)/g, - function(matched, p1, p2, p3, p4) { - return p1 || p2 || p3 || p4; - } - ) - ); - } - - function regexEscape(s) { - return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); - } - - function absFloor(number) { - if (number < 0) { - // -0 -> 0 - return Math.ceil(number) || 0; - } else { - return Math.floor(number); - } - } - - function toInt(argumentForCoercion) { - var coercedNumber = +argumentForCoercion, - value = 0; - - if (coercedNumber !== 0 && isFinite(coercedNumber)) { - value = absFloor(coercedNumber); - } - - return value; - } - - var tokens = {}; - - function addParseToken(token, callback) { - var i, - func = callback, - tokenLen; - if (typeof token === 'string') { - token = [token]; - } - if (isNumber(callback)) { - func = function(input, array) { - array[callback] = toInt(input); - }; - } - tokenLen = token.length; - for (i = 0; i < tokenLen; i++) { - tokens[token[i]] = func; - } - } - - function addWeekParseToken(token, callback) { - addParseToken(token, function(input, array, config, token) { - config._w = config._w || {}; - callback(input, config._w, config, token); - }); - } - - function addTimeToArrayFromToken(token, input, config) { - if (input != null && hasOwnProp(tokens, token)) { - tokens[token](input, config._a, config, token); - } - } - - function isLeapYear(year) { - return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; - } - - var YEAR = 0, - MONTH = 1, - DATE = 2, - HOUR = 3, - MINUTE = 4, - SECOND = 5, - MILLISECOND = 6, - WEEK = 7, - WEEKDAY = 8; - - // FORMATTING - - addFormatToken('Y', 0, 0, function() { - var y = this.year(); - return y <= 9999 ? zeroFill(y, 4) : '+' + y; - }); - - addFormatToken(0, ['YY', 2], 0, function() { - return this.year() % 100; - }); - - addFormatToken(0, ['YYYY', 4], 0, 'year'); - addFormatToken(0, ['YYYYY', 5], 0, 'year'); - addFormatToken(0, ['YYYYYY', 6, true], 0, 'year'); - - // PARSING - - addRegexToken('Y', matchSigned); - addRegexToken('YY', match1to2, match2); - addRegexToken('YYYY', match1to4, match4); - addRegexToken('YYYYY', match1to6, match6); - addRegexToken('YYYYYY', match1to6, match6); - - addParseToken(['YYYYY', 'YYYYYY'], YEAR); - addParseToken('YYYY', function(input, array) { - array[YEAR] = - input.length === 2 ? hooks.parseTwoDigitYear(input) : toInt(input); - }); - addParseToken('YY', function(input, array) { - array[YEAR] = hooks.parseTwoDigitYear(input); - }); - addParseToken('Y', function(input, array) { - array[YEAR] = parseInt(input, 10); - }); - - // HELPERS - - function daysInYear(year) { - return isLeapYear(year) ? 366 : 365; - } - - // HOOKS - - hooks.parseTwoDigitYear = function(input) { - return toInt(input) + (toInt(input) > 68 ? 1900 : 2000); - }; - - // MOMENTS - - var getSetYear = makeGetSet('FullYear', true); - - function getIsLeapYear() { - return isLeapYear(this.year()); - } - - function makeGetSet(unit, keepTime) { - return function(value) { - if (value != null) { - set$1(this, unit, value); - hooks.updateOffset(this, keepTime); - return this; - } else { - return get(this, unit); - } - }; - } - - function get(mom, unit) { - if (!mom.isValid()) { - return NaN; - } - - var d = mom._d, - isUTC = mom._isUTC; - - switch (unit) { - case 'Milliseconds': - return isUTC ? d.getUTCMilliseconds() : d.getMilliseconds(); - case 'Seconds': - return isUTC ? d.getUTCSeconds() : d.getSeconds(); - case 'Minutes': - return isUTC ? d.getUTCMinutes() : d.getMinutes(); - case 'Hours': - return isUTC ? d.getUTCHours() : d.getHours(); - case 'Date': - return isUTC ? d.getUTCDate() : d.getDate(); - case 'Day': - return isUTC ? d.getUTCDay() : d.getDay(); - case 'Month': - return isUTC ? d.getUTCMonth() : d.getMonth(); - case 'FullYear': - return isUTC ? d.getUTCFullYear() : d.getFullYear(); - default: - return NaN; // Just in case - } - } - - function set$1(mom, unit, value) { - var d, isUTC, year, month, date; - - if (!mom.isValid() || isNaN(value)) { - return; - } - - d = mom._d; - isUTC = mom._isUTC; - - switch (unit) { - case 'Milliseconds': - return void(isUTC ? - d.setUTCMilliseconds(value) : - d.setMilliseconds(value)); - case 'Seconds': - return void(isUTC ? d.setUTCSeconds(value) : d.setSeconds(value)); - case 'Minutes': - return void(isUTC ? d.setUTCMinutes(value) : d.setMinutes(value)); - case 'Hours': - return void(isUTC ? d.setUTCHours(value) : d.setHours(value)); - case 'Date': - return void(isUTC ? d.setUTCDate(value) : d.setDate(value)); - // case 'Day': // Not real - // return void (isUTC ? d.setUTCDay(value) : d.setDay(value)); - // case 'Month': // Not used because we need to pass two variables - // return void (isUTC ? d.setUTCMonth(value) : d.setMonth(value)); - case 'FullYear': - break; // See below ... - default: - return; // Just in case - } - - year = value; - month = mom.month(); - date = mom.date(); - date = date === 29 && month === 1 && !isLeapYear(year) ? 28 : date; - void(isUTC ? - d.setUTCFullYear(year, month, date) : - d.setFullYear(year, month, date)); - } - - // MOMENTS - - function stringGet(units) { - units = normalizeUnits(units); - if (isFunction(this[units])) { - return this[units](); - } - return this; - } - - function stringSet(units, value) { - if (typeof units === 'object') { - units = normalizeObjectUnits(units); - var prioritized = getPrioritizedUnits(units), - i, - prioritizedLen = prioritized.length; - for (i = 0; i < prioritizedLen; i++) { - this[prioritized[i].unit](units[prioritized[i].unit]); - } - } else { - units = normalizeUnits(units); - if (isFunction(this[units])) { - return this[units](value); - } - } - return this; - } - - function mod(n, x) { - return ((n % x) + x) % x; - } - - var indexOf; - - if (Array.prototype.indexOf) { - indexOf = Array.prototype.indexOf; - } else { - indexOf = function(o) { - // I know - var i; - for (i = 0; i < this.length; ++i) { - if (this[i] === o) { - return i; - } - } - return -1; - }; - } - - function daysInMonth(year, month) { - if (isNaN(year) || isNaN(month)) { - return NaN; - } - var modMonth = mod(month, 12); - year += (month - modMonth) / 12; - return modMonth === 1 ? - isLeapYear(year) ? - 29 : - 28 : - 31 - ((modMonth % 7) % 2); - } - - // FORMATTING - - addFormatToken('M', ['MM', 2], 'Mo', function() { - return this.month() + 1; - }); - - addFormatToken('MMM', 0, 0, function(format) { - return this.localeData().monthsShort(this, format); - }); - - addFormatToken('MMMM', 0, 0, function(format) { - return this.localeData().months(this, format); - }); - - // PARSING - - addRegexToken('M', match1to2, match1to2NoLeadingZero); - addRegexToken('MM', match1to2, match2); - addRegexToken('MMM', function(isStrict, locale) { - return locale.monthsShortRegex(isStrict); - }); - addRegexToken('MMMM', function(isStrict, locale) { - return locale.monthsRegex(isStrict); - }); - - addParseToken(['M', 'MM'], function(input, array) { - array[MONTH] = toInt(input) - 1; - }); - - addParseToken(['MMM', 'MMMM'], function(input, array, config, token) { - var month = config._locale.monthsParse(input, token, config._strict); - // if we didn't find a month name, mark the date as invalid. - if (month != null) { - array[MONTH] = month; - } else { - getParsingFlags(config).invalidMonth = input; - } - }); - - // LOCALES - - var defaultLocaleMonths = - 'January_February_March_April_May_June_July_August_September_October_November_December'.split( - '_' - ), - defaultLocaleMonthsShort = - 'Jan_Feb_Mar_Apr_May_Jun_Jul_Aug_Sep_Oct_Nov_Dec'.split('_'), - MONTHS_IN_FORMAT = /D[oD]?(\[[^\[\]]*\]|\s)+MMMM?/, - defaultMonthsShortRegex = matchWord, - defaultMonthsRegex = matchWord; - - function localeMonths(m, format) { - if (!m) { - return isArray(this._months) ? - this._months : - this._months['standalone']; - } - return isArray(this._months) ? - this._months[m.month()] : - this._months[ - (this._months.isFormat || MONTHS_IN_FORMAT).test(format) ? - 'format' : - 'standalone' - ][m.month()]; - } - - function localeMonthsShort(m, format) { - if (!m) { - return isArray(this._monthsShort) ? - this._monthsShort : - this._monthsShort['standalone']; - } - return isArray(this._monthsShort) ? - this._monthsShort[m.month()] : - this._monthsShort[ - MONTHS_IN_FORMAT.test(format) ? 'format' : 'standalone' - ][m.month()]; - } - - function handleStrictParse(monthName, format, strict) { - var i, - ii, - mom, - llc = monthName.toLocaleLowerCase(); - if (!this._monthsParse) { - // this is not used - this._monthsParse = []; - this._longMonthsParse = []; - this._shortMonthsParse = []; - for (i = 0; i < 12; ++i) { - mom = createUTC([2000, i]); - this._shortMonthsParse[i] = this.monthsShort( - mom, - '' - ).toLocaleLowerCase(); - this._longMonthsParse[i] = this.months(mom, '').toLocaleLowerCase(); - } - } - - if (strict) { - if (format === 'MMM') { - ii = indexOf.call(this._shortMonthsParse, llc); - return ii !== -1 ? ii : null; - } else { - ii = indexOf.call(this._longMonthsParse, llc); - return ii !== -1 ? ii : null; - } - } else { - if (format === 'MMM') { - ii = indexOf.call(this._shortMonthsParse, llc); - if (ii !== -1) { - return ii; - } - ii = indexOf.call(this._longMonthsParse, llc); - return ii !== -1 ? ii : null; - } else { - ii = indexOf.call(this._longMonthsParse, llc); - if (ii !== -1) { - return ii; - } - ii = indexOf.call(this._shortMonthsParse, llc); - return ii !== -1 ? ii : null; - } - } - } - - function localeMonthsParse(monthName, format, strict) { - var i, mom, regex; - - if (this._monthsParseExact) { - return handleStrictParse.call(this, monthName, format, strict); - } - - if (!this._monthsParse) { - this._monthsParse = []; - this._longMonthsParse = []; - this._shortMonthsParse = []; - } - - // TODO: add sorting - // Sorting makes sure if one month (or abbr) is a prefix of another - // see sorting in computeMonthsParse - for (i = 0; i < 12; i++) { - // make the regex if we don't have it already - mom = createUTC([2000, i]); - if (strict && !this._longMonthsParse[i]) { - this._longMonthsParse[i] = new RegExp( - '^' + this.months(mom, '').replace('.', '') + '$', - 'i' - ); - this._shortMonthsParse[i] = new RegExp( - '^' + this.monthsShort(mom, '').replace('.', '') + '$', - 'i' - ); - } - if (!strict && !this._monthsParse[i]) { - regex = - '^' + this.months(mom, '') + '|^' + this.monthsShort(mom, ''); - this._monthsParse[i] = new RegExp(regex.replace('.', ''), 'i'); - } - // test the regex - if ( - strict && - format === 'MMMM' && - this._longMonthsParse[i].test(monthName) - ) { - return i; - } else if ( - strict && - format === 'MMM' && - this._shortMonthsParse[i].test(monthName) - ) { - return i; - } else if (!strict && this._monthsParse[i].test(monthName)) { - return i; - } - } - } - - // MOMENTS - - function setMonth(mom, value) { - if (!mom.isValid()) { - // No op - return mom; - } - - if (typeof value === 'string') { - if (/^\d+$/.test(value)) { - value = toInt(value); - } else { - value = mom.localeData().monthsParse(value); - // TODO: Another silent failure? - if (!isNumber(value)) { - return mom; - } - } - } - - var month = value, - date = mom.date(); - - date = date < 29 ? date : Math.min(date, daysInMonth(mom.year(), month)); - void(mom._isUTC ? - mom._d.setUTCMonth(month, date) : - mom._d.setMonth(month, date)); - return mom; - } - - function getSetMonth(value) { - if (value != null) { - setMonth(this, value); - hooks.updateOffset(this, true); - return this; - } else { - return get(this, 'Month'); - } - } - - function getDaysInMonth() { - return daysInMonth(this.year(), this.month()); - } - - function monthsShortRegex(isStrict) { - if (this._monthsParseExact) { - if (!hasOwnProp(this, '_monthsRegex')) { - computeMonthsParse.call(this); - } - if (isStrict) { - return this._monthsShortStrictRegex; - } else { - return this._monthsShortRegex; - } - } else { - if (!hasOwnProp(this, '_monthsShortRegex')) { - this._monthsShortRegex = defaultMonthsShortRegex; - } - return this._monthsShortStrictRegex && isStrict ? - this._monthsShortStrictRegex : - this._monthsShortRegex; - } - } - - function monthsRegex(isStrict) { - if (this._monthsParseExact) { - if (!hasOwnProp(this, '_monthsRegex')) { - computeMonthsParse.call(this); - } - if (isStrict) { - return this._monthsStrictRegex; - } else { - return this._monthsRegex; - } - } else { - if (!hasOwnProp(this, '_monthsRegex')) { - this._monthsRegex = defaultMonthsRegex; - } - return this._monthsStrictRegex && isStrict ? - this._monthsStrictRegex : - this._monthsRegex; - } - } - - function computeMonthsParse() { - function cmpLenRev(a, b) { - return b.length - a.length; - } - - var shortPieces = [], - longPieces = [], - mixedPieces = [], - i, - mom, - shortP, - longP; - for (i = 0; i < 12; i++) { - // make the regex if we don't have it already - mom = createUTC([2000, i]); - shortP = regexEscape(this.monthsShort(mom, '')); - longP = regexEscape(this.months(mom, '')); - shortPieces.push(shortP); - longPieces.push(longP); - mixedPieces.push(longP); - mixedPieces.push(shortP); - } - // Sorting makes sure if one month (or abbr) is a prefix of another it - // will match the longer piece. - shortPieces.sort(cmpLenRev); - longPieces.sort(cmpLenRev); - mixedPieces.sort(cmpLenRev); - - this._monthsRegex = new RegExp('^(' + mixedPieces.join('|') + ')', 'i'); - this._monthsShortRegex = this._monthsRegex; - this._monthsStrictRegex = new RegExp( - '^(' + longPieces.join('|') + ')', - 'i' - ); - this._monthsShortStrictRegex = new RegExp( - '^(' + shortPieces.join('|') + ')', - 'i' - ); - } - - function createDate(y, m, d, h, M, s, ms) { - // can't just apply() to create a date: - // https://stackoverflow.com/q/181348 - var date; - // the date constructor remaps years 0-99 to 1900-1999 - if (y < 100 && y >= 0) { - // preserve leap years using a full 400 year cycle, then reset - date = new Date(y + 400, m, d, h, M, s, ms); - if (isFinite(date.getFullYear())) { - date.setFullYear(y); - } - } else { - date = new Date(y, m, d, h, M, s, ms); - } - - return date; - } - - function createUTCDate(y) { - var date, args; - // the Date.UTC function remaps years 0-99 to 1900-1999 - if (y < 100 && y >= 0) { - args = Array.prototype.slice.call(arguments); - // preserve leap years using a full 400 year cycle, then reset - args[0] = y + 400; - date = new Date(Date.UTC.apply(null, args)); - if (isFinite(date.getUTCFullYear())) { - date.setUTCFullYear(y); - } - } else { - date = new Date(Date.UTC.apply(null, arguments)); - } - - return date; - } - - // start-of-first-week - start-of-year - function firstWeekOffset(year, dow, doy) { - var // first-week day -- which january is always in the first week (4 for iso, 1 for other) - fwd = 7 + dow - doy, - // first-week day local weekday -- which local weekday is fwd - fwdlw = (7 + createUTCDate(year, 0, fwd).getUTCDay() - dow) % 7; - - return -fwdlw + fwd - 1; - } - - // https://en.wikipedia.org/wiki/ISO_week_date#Calculating_a_date_given_the_year.2C_week_number_and_weekday - function dayOfYearFromWeeks(year, week, weekday, dow, doy) { - var localWeekday = (7 + weekday - dow) % 7, - weekOffset = firstWeekOffset(year, dow, doy), - dayOfYear = 1 + 7 * (week - 1) + localWeekday + weekOffset, - resYear, - resDayOfYear; - - if (dayOfYear <= 0) { - resYear = year - 1; - resDayOfYear = daysInYear(resYear) + dayOfYear; - } else if (dayOfYear > daysInYear(year)) { - resYear = year + 1; - resDayOfYear = dayOfYear - daysInYear(year); - } else { - resYear = year; - resDayOfYear = dayOfYear; - } - - return { - year: resYear, - dayOfYear: resDayOfYear, - }; - } - - function weekOfYear(mom, dow, doy) { - var weekOffset = firstWeekOffset(mom.year(), dow, doy), - week = Math.floor((mom.dayOfYear() - weekOffset - 1) / 7) + 1, - resWeek, - resYear; - - if (week < 1) { - resYear = mom.year() - 1; - resWeek = week + weeksInYear(resYear, dow, doy); - } else if (week > weeksInYear(mom.year(), dow, doy)) { - resWeek = week - weeksInYear(mom.year(), dow, doy); - resYear = mom.year() + 1; - } else { - resYear = mom.year(); - resWeek = week; - } - - return { - week: resWeek, - year: resYear, - }; - } - - function weeksInYear(year, dow, doy) { - var weekOffset = firstWeekOffset(year, dow, doy), - weekOffsetNext = firstWeekOffset(year + 1, dow, doy); - return (daysInYear(year) - weekOffset + weekOffsetNext) / 7; - } - - // FORMATTING - - addFormatToken('w', ['ww', 2], 'wo', 'week'); - addFormatToken('W', ['WW', 2], 'Wo', 'isoWeek'); - - // PARSING - - addRegexToken('w', match1to2, match1to2NoLeadingZero); - addRegexToken('ww', match1to2, match2); - addRegexToken('W', match1to2, match1to2NoLeadingZero); - addRegexToken('WW', match1to2, match2); - - addWeekParseToken( - ['w', 'ww', 'W', 'WW'], - function(input, week, config, token) { - week[token.substr(0, 1)] = toInt(input); - } - ); - - // HELPERS - - // LOCALES - - function localeWeek(mom) { - return weekOfYear(mom, this._week.dow, this._week.doy).week; - } - - var defaultLocaleWeek = { - dow: 0, // Sunday is the first day of the week. - doy: 6, // The week that contains Jan 6th is the first week of the year. - }; - - function localeFirstDayOfWeek() { - return this._week.dow; - } - - function localeFirstDayOfYear() { - return this._week.doy; - } - - // MOMENTS - - function getSetWeek(input) { - var week = this.localeData().week(this); - return input == null ? week : this.add((input - week) * 7, 'd'); - } - - function getSetISOWeek(input) { - var week = weekOfYear(this, 1, 4).week; - return input == null ? week : this.add((input - week) * 7, 'd'); - } - - // FORMATTING - - addFormatToken('d', 0, 'do', 'day'); - - addFormatToken('dd', 0, 0, function(format) { - return this.localeData().weekdaysMin(this, format); - }); - - addFormatToken('ddd', 0, 0, function(format) { - return this.localeData().weekdaysShort(this, format); - }); - - addFormatToken('dddd', 0, 0, function(format) { - return this.localeData().weekdays(this, format); - }); - - addFormatToken('e', 0, 0, 'weekday'); - addFormatToken('E', 0, 0, 'isoWeekday'); - - // PARSING - - addRegexToken('d', match1to2); - addRegexToken('e', match1to2); - addRegexToken('E', match1to2); - addRegexToken('dd', function(isStrict, locale) { - return locale.weekdaysMinRegex(isStrict); - }); - addRegexToken('ddd', function(isStrict, locale) { - return locale.weekdaysShortRegex(isStrict); - }); - addRegexToken('dddd', function(isStrict, locale) { - return locale.weekdaysRegex(isStrict); - }); - - addWeekParseToken(['dd', 'ddd', 'dddd'], function(input, week, config, token) { - var weekday = config._locale.weekdaysParse(input, token, config._strict); - // if we didn't get a weekday name, mark the date as invalid - if (weekday != null) { - week.d = weekday; - } else { - getParsingFlags(config).invalidWeekday = input; - } - }); - - addWeekParseToken(['d', 'e', 'E'], function(input, week, config, token) { - week[token] = toInt(input); - }); - - // HELPERS - - function parseWeekday(input, locale) { - if (typeof input !== 'string') { - return input; - } - - if (!isNaN(input)) { - return parseInt(input, 10); - } - - input = locale.weekdaysParse(input); - if (typeof input === 'number') { - return input; - } - - return null; - } - - function parseIsoWeekday(input, locale) { - if (typeof input === 'string') { - return locale.weekdaysParse(input) % 7 || 7; - } - return isNaN(input) ? null : input; - } - - // LOCALES - function shiftWeekdays(ws, n) { - return ws.slice(n, 7).concat(ws.slice(0, n)); - } - - var defaultLocaleWeekdays = - 'Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday'.split('_'), - defaultLocaleWeekdaysShort = 'Sun_Mon_Tue_Wed_Thu_Fri_Sat'.split('_'), - defaultLocaleWeekdaysMin = 'Su_Mo_Tu_We_Th_Fr_Sa'.split('_'), - defaultWeekdaysRegex = matchWord, - defaultWeekdaysShortRegex = matchWord, - defaultWeekdaysMinRegex = matchWord; - - function localeWeekdays(m, format) { - var weekdays = isArray(this._weekdays) ? - this._weekdays : - this._weekdays[ - m && m !== true && this._weekdays.isFormat.test(format) ? - 'format' : - 'standalone' - ]; - return m === true ? - shiftWeekdays(weekdays, this._week.dow) : - m ? - weekdays[m.day()] : - weekdays; - } - - function localeWeekdaysShort(m) { - return m === true ? - shiftWeekdays(this._weekdaysShort, this._week.dow) : - m ? - this._weekdaysShort[m.day()] : - this._weekdaysShort; - } - - function localeWeekdaysMin(m) { - return m === true ? - shiftWeekdays(this._weekdaysMin, this._week.dow) : - m ? - this._weekdaysMin[m.day()] : - this._weekdaysMin; - } - - function handleStrictParse$1(weekdayName, format, strict) { - var i, - ii, - mom, - llc = weekdayName.toLocaleLowerCase(); - if (!this._weekdaysParse) { - this._weekdaysParse = []; - this._shortWeekdaysParse = []; - this._minWeekdaysParse = []; - - for (i = 0; i < 7; ++i) { - mom = createUTC([2000, 1]).day(i); - this._minWeekdaysParse[i] = this.weekdaysMin( - mom, - '' - ).toLocaleLowerCase(); - this._shortWeekdaysParse[i] = this.weekdaysShort( - mom, - '' - ).toLocaleLowerCase(); - this._weekdaysParse[i] = this.weekdays(mom, '').toLocaleLowerCase(); - } - } - - if (strict) { - if (format === 'dddd') { - ii = indexOf.call(this._weekdaysParse, llc); - return ii !== -1 ? ii : null; - } else if (format === 'ddd') { - ii = indexOf.call(this._shortWeekdaysParse, llc); - return ii !== -1 ? ii : null; - } else { - ii = indexOf.call(this._minWeekdaysParse, llc); - return ii !== -1 ? ii : null; - } - } else { - if (format === 'dddd') { - ii = indexOf.call(this._weekdaysParse, llc); - if (ii !== -1) { - return ii; - } - ii = indexOf.call(this._shortWeekdaysParse, llc); - if (ii !== -1) { - return ii; - } - ii = indexOf.call(this._minWeekdaysParse, llc); - return ii !== -1 ? ii : null; - } else if (format === 'ddd') { - ii = indexOf.call(this._shortWeekdaysParse, llc); - if (ii !== -1) { - return ii; - } - ii = indexOf.call(this._weekdaysParse, llc); - if (ii !== -1) { - return ii; - } - ii = indexOf.call(this._minWeekdaysParse, llc); - return ii !== -1 ? ii : null; - } else { - ii = indexOf.call(this._minWeekdaysParse, llc); - if (ii !== -1) { - return ii; - } - ii = indexOf.call(this._weekdaysParse, llc); - if (ii !== -1) { - return ii; - } - ii = indexOf.call(this._shortWeekdaysParse, llc); - return ii !== -1 ? ii : null; - } - } - } - - function localeWeekdaysParse(weekdayName, format, strict) { - var i, mom, regex; - - if (this._weekdaysParseExact) { - return handleStrictParse$1.call(this, weekdayName, format, strict); - } - - if (!this._weekdaysParse) { - this._weekdaysParse = []; - this._minWeekdaysParse = []; - this._shortWeekdaysParse = []; - this._fullWeekdaysParse = []; - } - - for (i = 0; i < 7; i++) { - // make the regex if we don't have it already - - mom = createUTC([2000, 1]).day(i); - if (strict && !this._fullWeekdaysParse[i]) { - this._fullWeekdaysParse[i] = new RegExp( - '^' + this.weekdays(mom, '').replace('.', '\\.?') + '$', - 'i' - ); - this._shortWeekdaysParse[i] = new RegExp( - '^' + this.weekdaysShort(mom, '').replace('.', '\\.?') + '$', - 'i' - ); - this._minWeekdaysParse[i] = new RegExp( - '^' + this.weekdaysMin(mom, '').replace('.', '\\.?') + '$', - 'i' - ); - } - if (!this._weekdaysParse[i]) { - regex = - '^' + - this.weekdays(mom, '') + - '|^' + - this.weekdaysShort(mom, '') + - '|^' + - this.weekdaysMin(mom, ''); - this._weekdaysParse[i] = new RegExp(regex.replace('.', ''), 'i'); - } - // test the regex - if ( - strict && - format === 'dddd' && - this._fullWeekdaysParse[i].test(weekdayName) - ) { - return i; - } else if ( - strict && - format === 'ddd' && - this._shortWeekdaysParse[i].test(weekdayName) - ) { - return i; - } else if ( - strict && - format === 'dd' && - this._minWeekdaysParse[i].test(weekdayName) - ) { - return i; - } else if (!strict && this._weekdaysParse[i].test(weekdayName)) { - return i; - } - } - } - - // MOMENTS - - function getSetDayOfWeek(input) { - if (!this.isValid()) { - return input != null ? this : NaN; - } - - var day = get(this, 'Day'); - if (input != null) { - input = parseWeekday(input, this.localeData()); - return this.add(input - day, 'd'); - } else { - return day; - } - } - - function getSetLocaleDayOfWeek(input) { - if (!this.isValid()) { - return input != null ? this : NaN; - } - var weekday = (this.day() + 7 - this.localeData()._week.dow) % 7; - return input == null ? weekday : this.add(input - weekday, 'd'); - } - - function getSetISODayOfWeek(input) { - if (!this.isValid()) { - return input != null ? this : NaN; - } - - // behaves the same as moment#day except - // as a getter, returns 7 instead of 0 (1-7 range instead of 0-6) - // as a setter, sunday should belong to the previous week. - - if (input != null) { - var weekday = parseIsoWeekday(input, this.localeData()); - return this.day(this.day() % 7 ? weekday : weekday - 7); - } else { - return this.day() || 7; - } - } - - function weekdaysRegex(isStrict) { - if (this._weekdaysParseExact) { - if (!hasOwnProp(this, '_weekdaysRegex')) { - computeWeekdaysParse.call(this); - } - if (isStrict) { - return this._weekdaysStrictRegex; - } else { - return this._weekdaysRegex; - } - } else { - if (!hasOwnProp(this, '_weekdaysRegex')) { - this._weekdaysRegex = defaultWeekdaysRegex; - } - return this._weekdaysStrictRegex && isStrict ? - this._weekdaysStrictRegex : - this._weekdaysRegex; - } - } - - function weekdaysShortRegex(isStrict) { - if (this._weekdaysParseExact) { - if (!hasOwnProp(this, '_weekdaysRegex')) { - computeWeekdaysParse.call(this); - } - if (isStrict) { - return this._weekdaysShortStrictRegex; - } else { - return this._weekdaysShortRegex; - } - } else { - if (!hasOwnProp(this, '_weekdaysShortRegex')) { - this._weekdaysShortRegex = defaultWeekdaysShortRegex; - } - return this._weekdaysShortStrictRegex && isStrict ? - this._weekdaysShortStrictRegex : - this._weekdaysShortRegex; - } - } - - function weekdaysMinRegex(isStrict) { - if (this._weekdaysParseExact) { - if (!hasOwnProp(this, '_weekdaysRegex')) { - computeWeekdaysParse.call(this); - } - if (isStrict) { - return this._weekdaysMinStrictRegex; - } else { - return this._weekdaysMinRegex; - } - } else { - if (!hasOwnProp(this, '_weekdaysMinRegex')) { - this._weekdaysMinRegex = defaultWeekdaysMinRegex; - } - return this._weekdaysMinStrictRegex && isStrict ? - this._weekdaysMinStrictRegex : - this._weekdaysMinRegex; - } - } - - function computeWeekdaysParse() { - function cmpLenRev(a, b) { - return b.length - a.length; - } - - var minPieces = [], - shortPieces = [], - longPieces = [], - mixedPieces = [], - i, - mom, - minp, - shortp, - longp; - for (i = 0; i < 7; i++) { - // make the regex if we don't have it already - mom = createUTC([2000, 1]).day(i); - minp = regexEscape(this.weekdaysMin(mom, '')); - shortp = regexEscape(this.weekdaysShort(mom, '')); - longp = regexEscape(this.weekdays(mom, '')); - minPieces.push(minp); - shortPieces.push(shortp); - longPieces.push(longp); - mixedPieces.push(minp); - mixedPieces.push(shortp); - mixedPieces.push(longp); - } - // Sorting makes sure if one weekday (or abbr) is a prefix of another it - // will match the longer piece. - minPieces.sort(cmpLenRev); - shortPieces.sort(cmpLenRev); - longPieces.sort(cmpLenRev); - mixedPieces.sort(cmpLenRev); - - this._weekdaysRegex = new RegExp('^(' + mixedPieces.join('|') + ')', 'i'); - this._weekdaysShortRegex = this._weekdaysRegex; - this._weekdaysMinRegex = this._weekdaysRegex; - - this._weekdaysStrictRegex = new RegExp( - '^(' + longPieces.join('|') + ')', - 'i' - ); - this._weekdaysShortStrictRegex = new RegExp( - '^(' + shortPieces.join('|') + ')', - 'i' - ); - this._weekdaysMinStrictRegex = new RegExp( - '^(' + minPieces.join('|') + ')', - 'i' - ); - } - - // FORMATTING - - function hFormat() { - return this.hours() % 12 || 12; - } - - function kFormat() { - return this.hours() || 24; - } - - addFormatToken('H', ['HH', 2], 0, 'hour'); - addFormatToken('h', ['hh', 2], 0, hFormat); - addFormatToken('k', ['kk', 2], 0, kFormat); - - addFormatToken('hmm', 0, 0, function() { - return '' + hFormat.apply(this) + zeroFill(this.minutes(), 2); - }); - - addFormatToken('hmmss', 0, 0, function() { - return ( - '' + - hFormat.apply(this) + - zeroFill(this.minutes(), 2) + - zeroFill(this.seconds(), 2) - ); - }); - - addFormatToken('Hmm', 0, 0, function() { - return '' + this.hours() + zeroFill(this.minutes(), 2); - }); - - addFormatToken('Hmmss', 0, 0, function() { - return ( - '' + - this.hours() + - zeroFill(this.minutes(), 2) + - zeroFill(this.seconds(), 2) - ); - }); - - function meridiem(token, lowercase) { - addFormatToken(token, 0, 0, function() { - return this.localeData().meridiem( - this.hours(), - this.minutes(), - lowercase - ); - }); - } - - meridiem('a', true); - meridiem('A', false); - - // PARSING - - function matchMeridiem(isStrict, locale) { - return locale._meridiemParse; - } - - addRegexToken('a', matchMeridiem); - addRegexToken('A', matchMeridiem); - addRegexToken('H', match1to2, match1to2HasZero); - addRegexToken('h', match1to2, match1to2NoLeadingZero); - addRegexToken('k', match1to2, match1to2NoLeadingZero); - addRegexToken('HH', match1to2, match2); - addRegexToken('hh', match1to2, match2); - addRegexToken('kk', match1to2, match2); - - addRegexToken('hmm', match3to4); - addRegexToken('hmmss', match5to6); - addRegexToken('Hmm', match3to4); - addRegexToken('Hmmss', match5to6); - - addParseToken(['H', 'HH'], HOUR); - addParseToken(['k', 'kk'], function(input, array, config) { - var kInput = toInt(input); - array[HOUR] = kInput === 24 ? 0 : kInput; - }); - addParseToken(['a', 'A'], function(input, array, config) { - config._isPm = config._locale.isPM(input); - config._meridiem = input; - }); - addParseToken(['h', 'hh'], function(input, array, config) { - array[HOUR] = toInt(input); - getParsingFlags(config).bigHour = true; - }); - addParseToken('hmm', function(input, array, config) { - var pos = input.length - 2; - array[HOUR] = toInt(input.substr(0, pos)); - array[MINUTE] = toInt(input.substr(pos)); - getParsingFlags(config).bigHour = true; - }); - addParseToken('hmmss', function(input, array, config) { - var pos1 = input.length - 4, - pos2 = input.length - 2; - array[HOUR] = toInt(input.substr(0, pos1)); - array[MINUTE] = toInt(input.substr(pos1, 2)); - array[SECOND] = toInt(input.substr(pos2)); - getParsingFlags(config).bigHour = true; - }); - addParseToken('Hmm', function(input, array, config) { - var pos = input.length - 2; - array[HOUR] = toInt(input.substr(0, pos)); - array[MINUTE] = toInt(input.substr(pos)); - }); - addParseToken('Hmmss', function(input, array, config) { - var pos1 = input.length - 4, - pos2 = input.length - 2; - array[HOUR] = toInt(input.substr(0, pos1)); - array[MINUTE] = toInt(input.substr(pos1, 2)); - array[SECOND] = toInt(input.substr(pos2)); - }); - - // LOCALES - - function localeIsPM(input) { - // IE8 Quirks Mode & IE7 Standards Mode do not allow accessing strings like arrays - // Using charAt should be more compatible. - return (input + '').toLowerCase().charAt(0) === 'p'; - } - - var defaultLocaleMeridiemParse = /[ap]\.?m?\.?/i, - // Setting the hour should keep the time, because the user explicitly - // specified which hour they want. So trying to maintain the same hour (in - // a new timezone) makes sense. Adding/subtracting hours does not follow - // this rule. - getSetHour = makeGetSet('Hours', true); - - function localeMeridiem(hours, minutes, isLower) { - if (hours > 11) { - return isLower ? 'pm' : 'PM'; - } else { - return isLower ? 'am' : 'AM'; - } - } - - var baseConfig = { - calendar: defaultCalendar, - longDateFormat: defaultLongDateFormat, - invalidDate: defaultInvalidDate, - ordinal: defaultOrdinal, - dayOfMonthOrdinalParse: defaultDayOfMonthOrdinalParse, - relativeTime: defaultRelativeTime, - - months: defaultLocaleMonths, - monthsShort: defaultLocaleMonthsShort, - - week: defaultLocaleWeek, - - weekdays: defaultLocaleWeekdays, - weekdaysMin: defaultLocaleWeekdaysMin, - weekdaysShort: defaultLocaleWeekdaysShort, - - meridiemParse: defaultLocaleMeridiemParse, - }; - - // internal storage for locale config files - var locales = {}, - localeFamilies = {}, - globalLocale; - - function commonPrefix(arr1, arr2) { - var i, - minl = Math.min(arr1.length, arr2.length); - for (i = 0; i < minl; i += 1) { - if (arr1[i] !== arr2[i]) { - return i; - } - } - return minl; - } - - function normalizeLocale(key) { - return key ? key.toLowerCase().replace('_', '-') : key; - } - - // pick the locale from the array - // try ['en-au', 'en-gb'] as 'en-au', 'en-gb', 'en', as in move through the list trying each - // substring from most specific to least, but move to the next array item if it's a more specific variant than the current root - function chooseLocale(names) { - var i = 0, - j, - next, - locale, - split; - - while (i < names.length) { - split = normalizeLocale(names[i]).split('-'); - j = split.length; - next = normalizeLocale(names[i + 1]); - next = next ? next.split('-') : null; - while (j > 0) { - locale = loadLocale(split.slice(0, j).join('-')); - if (locale) { - return locale; - } - if ( - next && - next.length >= j && - commonPrefix(split, next) >= j - 1 - ) { - //the next array item is better than a shallower substring of this one - break; - } - j--; - } - i++; - } - return globalLocale; - } - - function isLocaleNameSane(name) { - // Prevent names that look like filesystem paths, i.e contain '/' or '\' - // Ensure name is available and function returns boolean - return !!(name && name.match('^[^/\\\\]*$')); - } - - function loadLocale(name) { - var oldLocale = null, - aliasedRequire; - // TODO: Find a better way to register and load all the locales in Node - if ( - locales[name] === undefined && - typeof module !== 'undefined' && - module && - module.exports && - isLocaleNameSane(name) - ) { - try { - oldLocale = globalLocale._abbr; - aliasedRequire = require; - aliasedRequire('./locale/' + name); - getSetGlobalLocale(oldLocale); - } catch (e) { - // mark as not found to avoid repeating expensive file require call causing high CPU - // when trying to find en-US, en_US, en-us for every format call - locales[name] = null; // null means not found - } - } - return locales[name]; - } - - // This function will load locale and then set the global locale. If - // no arguments are passed in, it will simply return the current global - // locale key. - function getSetGlobalLocale(key, values) { - var data; - if (key) { - if (isUndefined(values)) { - data = getLocale(key); - } else { - data = defineLocale(key, values); - } - - if (data) { - // moment.duration._locale = moment._locale = data; - globalLocale = data; - } else { - if (typeof console !== 'undefined' && console.warn) { - //warn user if arguments are passed but the locale could not be set - console.warn( - 'Locale ' + key + ' not found. Did you forget to load it?' - ); - } - } - } - - return globalLocale._abbr; - } - - function defineLocale(name, config) { - if (config !== null) { - var locale, - parentConfig = baseConfig; - config.abbr = name; - if (locales[name] != null) { - deprecateSimple( - 'defineLocaleOverride', - 'use moment.updateLocale(localeName, config) to change ' + - 'an existing locale. moment.defineLocale(localeName, ' + - 'config) should only be used for creating a new locale ' + - 'See http://momentjs.com/guides/#/warnings/define-locale/ for more info.' - ); - parentConfig = locales[name]._config; - } else if (config.parentLocale != null) { - if (locales[config.parentLocale] != null) { - parentConfig = locales[config.parentLocale]._config; - } else { - locale = loadLocale(config.parentLocale); - if (locale != null) { - parentConfig = locale._config; - } else { - if (!localeFamilies[config.parentLocale]) { - localeFamilies[config.parentLocale] = []; - } - localeFamilies[config.parentLocale].push({ - name: name, - config: config, - }); - return null; - } - } - } - locales[name] = new Locale(mergeConfigs(parentConfig, config)); - - if (localeFamilies[name]) { - localeFamilies[name].forEach(function(x) { - defineLocale(x.name, x.config); - }); - } - - // backwards compat for now: also set the locale - // make sure we set the locale AFTER all child locales have been - // created, so we won't end up with the child locale set. - getSetGlobalLocale(name); - - return locales[name]; - } else { - // useful for testing - delete locales[name]; - return null; - } - } - - function updateLocale(name, config) { - if (config != null) { - var locale, - tmpLocale, - parentConfig = baseConfig; - - if (locales[name] != null && locales[name].parentLocale != null) { - // Update existing child locale in-place to avoid memory-leaks - locales[name].set(mergeConfigs(locales[name]._config, config)); - } else { - // MERGE - tmpLocale = loadLocale(name); - if (tmpLocale != null) { - parentConfig = tmpLocale._config; - } - config = mergeConfigs(parentConfig, config); - if (tmpLocale == null) { - // updateLocale is called for creating a new locale - // Set abbr so it will have a name (getters return - // undefined otherwise). - config.abbr = name; - } - locale = new Locale(config); - locale.parentLocale = locales[name]; - locales[name] = locale; - } - - // backwards compat for now: also set the locale - getSetGlobalLocale(name); - } else { - // pass null for config to unupdate, useful for tests - if (locales[name] != null) { - if (locales[name].parentLocale != null) { - locales[name] = locales[name].parentLocale; - if (name === getSetGlobalLocale()) { - getSetGlobalLocale(name); - } - } else if (locales[name] != null) { - delete locales[name]; - } - } - } - return locales[name]; - } - - // returns locale data - function getLocale(key) { - var locale; - - if (key && key._locale && key._locale._abbr) { - key = key._locale._abbr; - } - - if (!key) { - return globalLocale; - } - - if (!isArray(key)) { - //short-circuit everything else - locale = loadLocale(key); - if (locale) { - return locale; - } - key = [key]; - } - - return chooseLocale(key); - } - - function listLocales() { - return keys(locales); - } - - function checkOverflow(m) { - var overflow, - a = m._a; - - if (a && getParsingFlags(m).overflow === -2) { - overflow = - a[MONTH] < 0 || a[MONTH] > 11 ? - MONTH : - a[DATE] < 1 || a[DATE] > daysInMonth(a[YEAR], a[MONTH]) ? - DATE : - a[HOUR] < 0 || - a[HOUR] > 24 || - (a[HOUR] === 24 && - (a[MINUTE] !== 0 || - a[SECOND] !== 0 || - a[MILLISECOND] !== 0)) ? - HOUR : - a[MINUTE] < 0 || a[MINUTE] > 59 ? - MINUTE : - a[SECOND] < 0 || a[SECOND] > 59 ? - SECOND : - a[MILLISECOND] < 0 || a[MILLISECOND] > 999 ? - MILLISECOND : - -1; - - if ( - getParsingFlags(m)._overflowDayOfYear && - (overflow < YEAR || overflow > DATE) - ) { - overflow = DATE; - } - if (getParsingFlags(m)._overflowWeeks && overflow === -1) { - overflow = WEEK; - } - if (getParsingFlags(m)._overflowWeekday && overflow === -1) { - overflow = WEEKDAY; - } - - getParsingFlags(m).overflow = overflow; - } - - return m; - } - - // iso 8601 regex - // 0000-00-00 0000-W00 or 0000-W00-0 + T + 00 or 00:00 or 00:00:00 or 00:00:00.000 + +00:00 or +0000 or +00) - var extendedIsoRegex = - /^\s*((?:[+-]\d{6}|\d{4})-(?:\d\d-\d\d|W\d\d-\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?::\d\d(?::\d\d(?:[.,]\d+)?)?)?)([+-]\d\d(?::?\d\d)?|\s*Z)?)?$/, - basicIsoRegex = - /^\s*((?:[+-]\d{6}|\d{4})(?:\d\d\d\d|W\d\d\d|W\d\d|\d\d\d|\d\d|))(?:(T| )(\d\d(?:\d\d(?:\d\d(?:[.,]\d+)?)?)?)([+-]\d\d(?::?\d\d)?|\s*Z)?)?$/, - tzRegex = /Z|[+-]\d\d(?::?\d\d)?/, - isoDates = [ - ['YYYYYY-MM-DD', /[+-]\d{6}-\d\d-\d\d/], - ['YYYY-MM-DD', /\d{4}-\d\d-\d\d/], - ['GGGG-[W]WW-E', /\d{4}-W\d\d-\d/], - ['GGGG-[W]WW', /\d{4}-W\d\d/, false], - ['YYYY-DDD', /\d{4}-\d{3}/], - ['YYYY-MM', /\d{4}-\d\d/, false], - ['YYYYYYMMDD', /[+-]\d{10}/], - ['YYYYMMDD', /\d{8}/], - ['GGGG[W]WWE', /\d{4}W\d{3}/], - ['GGGG[W]WW', /\d{4}W\d{2}/, false], - ['YYYYDDD', /\d{7}/], - ['YYYYMM', /\d{6}/, false], - ['YYYY', /\d{4}/, false], - ], - // iso time formats and regexes - isoTimes = [ - ['HH:mm:ss.SSSS', /\d\d:\d\d:\d\d\.\d+/], - ['HH:mm:ss,SSSS', /\d\d:\d\d:\d\d,\d+/], - ['HH:mm:ss', /\d\d:\d\d:\d\d/], - ['HH:mm', /\d\d:\d\d/], - ['HHmmss.SSSS', /\d\d\d\d\d\d\.\d+/], - ['HHmmss,SSSS', /\d\d\d\d\d\d,\d+/], - ['HHmmss', /\d\d\d\d\d\d/], - ['HHmm', /\d\d\d\d/], - ['HH', /\d\d/], - ], - aspNetJsonRegex = /^\/?Date\((-?\d+)/i, - // RFC 2822 regex: For details see https://tools.ietf.org/html/rfc2822#section-3.3 - rfc2822 = - /^(?:(Mon|Tue|Wed|Thu|Fri|Sat|Sun),?\s)?(\d{1,2})\s(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s(\d{2,4})\s(\d\d):(\d\d)(?::(\d\d))?\s(?:(UT|GMT|[ECMP][SD]T)|([Zz])|([+-]\d{4}))$/, - obsOffsets = { - UT: 0, - GMT: 0, - EDT: -4 * 60, - EST: -5 * 60, - CDT: -5 * 60, - CST: -6 * 60, - MDT: -6 * 60, - MST: -7 * 60, - PDT: -7 * 60, - PST: -8 * 60, - }; - - // date from iso format - function configFromISO(config) { - var i, - l, - string = config._i, - match = extendedIsoRegex.exec(string) || basicIsoRegex.exec(string), - allowTime, - dateFormat, - timeFormat, - tzFormat, - isoDatesLen = isoDates.length, - isoTimesLen = isoTimes.length; - - if (match) { - getParsingFlags(config).iso = true; - for (i = 0, l = isoDatesLen; i < l; i++) { - if (isoDates[i][1].exec(match[1])) { - dateFormat = isoDates[i][0]; - allowTime = isoDates[i][2] !== false; - break; - } - } - if (dateFormat == null) { - config._isValid = false; - return; - } - if (match[3]) { - for (i = 0, l = isoTimesLen; i < l; i++) { - if (isoTimes[i][1].exec(match[3])) { - // match[2] should be 'T' or space - timeFormat = (match[2] || ' ') + isoTimes[i][0]; - break; - } - } - if (timeFormat == null) { - config._isValid = false; - return; - } - } - if (!allowTime && timeFormat != null) { - config._isValid = false; - return; - } - if (match[4]) { - if (tzRegex.exec(match[4])) { - tzFormat = 'Z'; - } else { - config._isValid = false; - return; - } - } - config._f = dateFormat + (timeFormat || '') + (tzFormat || ''); - configFromStringAndFormat(config); - } else { - config._isValid = false; - } - } - - function extractFromRFC2822Strings( - yearStr, - monthStr, - dayStr, - hourStr, - minuteStr, - secondStr - ) { - var result = [ - untruncateYear(yearStr), - defaultLocaleMonthsShort.indexOf(monthStr), - parseInt(dayStr, 10), - parseInt(hourStr, 10), - parseInt(minuteStr, 10), - ]; - - if (secondStr) { - result.push(parseInt(secondStr, 10)); - } - - return result; - } - - function untruncateYear(yearStr) { - var year = parseInt(yearStr, 10); - if (year <= 49) { - return 2000 + year; - } else if (year <= 999) { - return 1900 + year; - } - return year; - } - - function preprocessRFC2822(s) { - // Remove comments and folding whitespace and replace multiple-spaces with a single space - return s - .replace(/\([^()]*\)|[\n\t]/g, ' ') - .replace(/(\s\s+)/g, ' ') - .replace(/^\s\s*/, '') - .replace(/\s\s*$/, ''); - } - - function checkWeekday(weekdayStr, parsedInput, config) { - if (weekdayStr) { - // TODO: Replace the vanilla JS Date object with an independent day-of-week check. - var weekdayProvided = defaultLocaleWeekdaysShort.indexOf(weekdayStr), - weekdayActual = new Date( - parsedInput[0], - parsedInput[1], - parsedInput[2] - ).getDay(); - if (weekdayProvided !== weekdayActual) { - getParsingFlags(config).weekdayMismatch = true; - config._isValid = false; - return false; - } - } - return true; - } - - function calculateOffset(obsOffset, militaryOffset, numOffset) { - if (obsOffset) { - return obsOffsets[obsOffset]; - } else if (militaryOffset) { - // the only allowed military tz is Z - return 0; - } else { - var hm = parseInt(numOffset, 10), - m = hm % 100, - h = (hm - m) / 100; - return h * 60 + m; - } - } - - // date and time from ref 2822 format - function configFromRFC2822(config) { - var match = rfc2822.exec(preprocessRFC2822(config._i)), - parsedArray; - if (match) { - parsedArray = extractFromRFC2822Strings( - match[4], - match[3], - match[2], - match[5], - match[6], - match[7] - ); - if (!checkWeekday(match[1], parsedArray, config)) { - return; - } - - config._a = parsedArray; - config._tzm = calculateOffset(match[8], match[9], match[10]); - - config._d = createUTCDate.apply(null, config._a); - config._d.setUTCMinutes(config._d.getUTCMinutes() - config._tzm); - - getParsingFlags(config).rfc2822 = true; - } else { - config._isValid = false; - } - } - - // date from 1) ASP.NET, 2) ISO, 3) RFC 2822 formats, or 4) optional fallback if parsing isn't strict - function configFromString(config) { - var matched = aspNetJsonRegex.exec(config._i); - if (matched !== null) { - config._d = new Date(+matched[1]); - return; - } - - configFromISO(config); - if (config._isValid === false) { - delete config._isValid; - } else { - return; - } - - configFromRFC2822(config); - if (config._isValid === false) { - delete config._isValid; - } else { - return; - } - - if (config._strict) { - config._isValid = false; - } else { - // Final attempt, use Input Fallback - hooks.createFromInputFallback(config); - } - } - - hooks.createFromInputFallback = deprecate( - 'value provided is not in a recognized RFC2822 or ISO format. moment construction falls back to js Date(), ' + - 'which is not reliable across all browsers and versions. Non RFC2822/ISO date formats are ' + - 'discouraged. Please refer to http://momentjs.com/guides/#/warnings/js-date/ for more info.', - function(config) { - config._d = new Date(config._i + (config._useUTC ? ' UTC' : '')); - } - ); - - // Pick the first defined of two or three arguments. - function defaults(a, b, c) { - if (a != null) { - return a; - } - if (b != null) { - return b; - } - return c; - } - - function currentDateArray(config) { - // hooks is actually the exported moment object - var nowValue = new Date(hooks.now()); - if (config._useUTC) { - return [ - nowValue.getUTCFullYear(), - nowValue.getUTCMonth(), - nowValue.getUTCDate(), - ]; - } - return [nowValue.getFullYear(), nowValue.getMonth(), nowValue.getDate()]; - } - - // convert an array to a date. - // the array should mirror the parameters below - // note: all values past the year are optional and will default to the lowest possible value. - // [year, month, day , hour, minute, second, millisecond] - function configFromArray(config) { - var i, - date, - input = [], - currentDate, - expectedWeekday, - yearToUse; - - if (config._d) { - return; - } - - currentDate = currentDateArray(config); - - //compute day of the year from weeks and weekdays - if (config._w && config._a[DATE] == null && config._a[MONTH] == null) { - dayOfYearFromWeekInfo(config); - } - - //if the day of the year is set, figure out what it is - if (config._dayOfYear != null) { - yearToUse = defaults(config._a[YEAR], currentDate[YEAR]); - - if ( - config._dayOfYear > daysInYear(yearToUse) || - config._dayOfYear === 0 - ) { - getParsingFlags(config)._overflowDayOfYear = true; - } - - date = createUTCDate(yearToUse, 0, config._dayOfYear); - config._a[MONTH] = date.getUTCMonth(); - config._a[DATE] = date.getUTCDate(); - } - - // Default to current date. - // * if no year, month, day of month are given, default to today - // * if day of month is given, default month and year - // * if month is given, default only year - // * if year is given, don't default anything - for (i = 0; i < 3 && config._a[i] == null; ++i) { - config._a[i] = input[i] = currentDate[i]; - } - - // Zero out whatever was not defaulted, including time - for (; i < 7; i++) { - config._a[i] = input[i] = - config._a[i] == null ? (i === 2 ? 1 : 0) : config._a[i]; - } - - // Check for 24:00:00.000 - if ( - config._a[HOUR] === 24 && - config._a[MINUTE] === 0 && - config._a[SECOND] === 0 && - config._a[MILLISECOND] === 0 - ) { - config._nextDay = true; - config._a[HOUR] = 0; - } - - config._d = (config._useUTC ? createUTCDate : createDate).apply( - null, - input - ); - expectedWeekday = config._useUTC ? - config._d.getUTCDay() : - config._d.getDay(); - - // Apply timezone offset from input. The actual utcOffset can be changed - // with parseZone. - if (config._tzm != null) { - config._d.setUTCMinutes(config._d.getUTCMinutes() - config._tzm); - } - - if (config._nextDay) { - config._a[HOUR] = 24; - } - - // check for mismatching day of week - if ( - config._w && - typeof config._w.d !== 'undefined' && - config._w.d !== expectedWeekday - ) { - getParsingFlags(config).weekdayMismatch = true; - } - } - - function dayOfYearFromWeekInfo(config) { - var w, weekYear, week, weekday, dow, doy, temp, weekdayOverflow, curWeek; - - w = config._w; - if (w.GG != null || w.W != null || w.E != null) { - dow = 1; - doy = 4; - - // TODO: We need to take the current isoWeekYear, but that depends on - // how we interpret now (local, utc, fixed offset). So create - // a now version of current config (take local/utc/offset flags, and - // create now). - weekYear = defaults( - w.GG, - config._a[YEAR], - weekOfYear(createLocal(), 1, 4).year - ); - week = defaults(w.W, 1); - weekday = defaults(w.E, 1); - if (weekday < 1 || weekday > 7) { - weekdayOverflow = true; - } - } else { - dow = config._locale._week.dow; - doy = config._locale._week.doy; - - curWeek = weekOfYear(createLocal(), dow, doy); - - weekYear = defaults(w.gg, config._a[YEAR], curWeek.year); - - // Default to current week. - week = defaults(w.w, curWeek.week); - - if (w.d != null) { - // weekday -- low day numbers are considered next week - weekday = w.d; - if (weekday < 0 || weekday > 6) { - weekdayOverflow = true; - } - } else if (w.e != null) { - // local weekday -- counting starts from beginning of week - weekday = w.e + dow; - if (w.e < 0 || w.e > 6) { - weekdayOverflow = true; - } - } else { - // default to beginning of week - weekday = dow; - } - } - if (week < 1 || week > weeksInYear(weekYear, dow, doy)) { - getParsingFlags(config)._overflowWeeks = true; - } else if (weekdayOverflow != null) { - getParsingFlags(config)._overflowWeekday = true; - } else { - temp = dayOfYearFromWeeks(weekYear, week, weekday, dow, doy); - config._a[YEAR] = temp.year; - config._dayOfYear = temp.dayOfYear; - } - } - - // constant that refers to the ISO standard - hooks.ISO_8601 = function() {}; - - // constant that refers to the RFC 2822 form - hooks.RFC_2822 = function() {}; - - // date from string and format string - function configFromStringAndFormat(config) { - // TODO: Move this to another part of the creation flow to prevent circular deps - if (config._f === hooks.ISO_8601) { - configFromISO(config); - return; - } - if (config._f === hooks.RFC_2822) { - configFromRFC2822(config); - return; - } - config._a = []; - getParsingFlags(config).empty = true; - - // This array is used to make a Date, either with `new Date` or `Date.UTC` - var string = '' + config._i, - i, - parsedInput, - tokens, - token, - skipped, - stringLength = string.length, - totalParsedInputLength = 0, - era, - tokenLen; - - tokens = - expandFormat(config._f, config._locale).match(formattingTokens) || []; - tokenLen = tokens.length; - for (i = 0; i < tokenLen; i++) { - token = tokens[i]; - parsedInput = (string.match(getParseRegexForToken(token, config)) || [])[0]; - if (parsedInput) { - skipped = string.substr(0, string.indexOf(parsedInput)); - if (skipped.length > 0) { - getParsingFlags(config).unusedInput.push(skipped); - } - string = string.slice( - string.indexOf(parsedInput) + parsedInput.length - ); - totalParsedInputLength += parsedInput.length; - } - // don't parse if it's not a known token - if (formatTokenFunctions[token]) { - if (parsedInput) { - getParsingFlags(config).empty = false; - } else { - getParsingFlags(config).unusedTokens.push(token); - } - addTimeToArrayFromToken(token, parsedInput, config); - } else if (config._strict && !parsedInput) { - getParsingFlags(config).unusedTokens.push(token); - } - } - - // add remaining unparsed input length to the string - getParsingFlags(config).charsLeftOver = - stringLength - totalParsedInputLength; - if (string.length > 0) { - getParsingFlags(config).unusedInput.push(string); - } - - // clear _12h flag if hour is <= 12 - if ( - config._a[HOUR] <= 12 && - getParsingFlags(config).bigHour === true && - config._a[HOUR] > 0 - ) { - getParsingFlags(config).bigHour = undefined; - } - - getParsingFlags(config).parsedDateParts = config._a.slice(0); - getParsingFlags(config).meridiem = config._meridiem; - // handle meridiem - config._a[HOUR] = meridiemFixWrap( - config._locale, - config._a[HOUR], - config._meridiem - ); - - // handle era - era = getParsingFlags(config).era; - if (era !== null) { - config._a[YEAR] = config._locale.erasConvertYear(era, config._a[YEAR]); - } - - configFromArray(config); - checkOverflow(config); - } - - function meridiemFixWrap(locale, hour, meridiem) { - var isPm; - - if (meridiem == null) { - // nothing to do - return hour; - } - if (locale.meridiemHour != null) { - return locale.meridiemHour(hour, meridiem); - } else if (locale.isPM != null) { - // Fallback - isPm = locale.isPM(meridiem); - if (isPm && hour < 12) { - hour += 12; - } - if (!isPm && hour === 12) { - hour = 0; - } - return hour; - } else { - // this is not supposed to happen - return hour; - } - } - - // date from string and array of format strings - function configFromStringAndArray(config) { - var tempConfig, - bestMoment, - scoreToBeat, - i, - currentScore, - validFormatFound, - bestFormatIsValid = false, - configfLen = config._f.length; - - if (configfLen === 0) { - getParsingFlags(config).invalidFormat = true; - config._d = new Date(NaN); - return; - } - - for (i = 0; i < configfLen; i++) { - currentScore = 0; - validFormatFound = false; - tempConfig = copyConfig({}, config); - if (config._useUTC != null) { - tempConfig._useUTC = config._useUTC; - } - tempConfig._f = config._f[i]; - configFromStringAndFormat(tempConfig); - - if (isValid(tempConfig)) { - validFormatFound = true; - } - - // if there is any input that was not parsed add a penalty for that format - currentScore += getParsingFlags(tempConfig).charsLeftOver; - - //or tokens - currentScore += getParsingFlags(tempConfig).unusedTokens.length * 10; - - getParsingFlags(tempConfig).score = currentScore; - - if (!bestFormatIsValid) { - if ( - scoreToBeat == null || - currentScore < scoreToBeat || - validFormatFound - ) { - scoreToBeat = currentScore; - bestMoment = tempConfig; - if (validFormatFound) { - bestFormatIsValid = true; - } - } - } else { - if (currentScore < scoreToBeat) { - scoreToBeat = currentScore; - bestMoment = tempConfig; - } - } - } - - extend(config, bestMoment || tempConfig); - } - - function configFromObject(config) { - if (config._d) { - return; - } - - var i = normalizeObjectUnits(config._i), - dayOrDate = i.day === undefined ? i.date : i.day; - config._a = map( - [i.year, i.month, dayOrDate, i.hour, i.minute, i.second, i.millisecond], - function(obj) { - return obj && parseInt(obj, 10); - } - ); - - configFromArray(config); - } - - function createFromConfig(config) { - var res = new Moment(checkOverflow(prepareConfig(config))); - if (res._nextDay) { - // Adding is smart enough around DST - res.add(1, 'd'); - res._nextDay = undefined; - } - - return res; - } - - function prepareConfig(config) { - var input = config._i, - format = config._f; - - config._locale = config._locale || getLocale(config._l); - - if (input === null || (format === undefined && input === '')) { - return createInvalid({ - nullInput: true - }); - } - - if (typeof input === 'string') { - config._i = input = config._locale.preparse(input); - } - - if (isMoment(input)) { - return new Moment(checkOverflow(input)); - } else if (isDate(input)) { - config._d = input; - } else if (isArray(format)) { - configFromStringAndArray(config); - } else if (format) { - configFromStringAndFormat(config); - } else { - configFromInput(config); - } - - if (!isValid(config)) { - config._d = null; - } - - return config; - } - - function configFromInput(config) { - var input = config._i; - if (isUndefined(input)) { - config._d = new Date(hooks.now()); - } else if (isDate(input)) { - config._d = new Date(input.valueOf()); - } else if (typeof input === 'string') { - configFromString(config); - } else if (isArray(input)) { - config._a = map(input.slice(0), function(obj) { - return parseInt(obj, 10); - }); - configFromArray(config); - } else if (isObject(input)) { - configFromObject(config); - } else if (isNumber(input)) { - // from milliseconds - config._d = new Date(input); - } else { - hooks.createFromInputFallback(config); - } - } - - function createLocalOrUTC(input, format, locale, strict, isUTC) { - var c = {}; - - if (format === true || format === false) { - strict = format; - format = undefined; - } - - if (locale === true || locale === false) { - strict = locale; - locale = undefined; - } - - if ( - (isObject(input) && isObjectEmpty(input)) || - (isArray(input) && input.length === 0) - ) { - input = undefined; - } - // object construction must be done this way. - // https://github.com/moment/moment/issues/1423 - c._isAMomentObject = true; - c._useUTC = c._isUTC = isUTC; - c._l = locale; - c._i = input; - c._f = format; - c._strict = strict; - - return createFromConfig(c); - } - - function createLocal(input, format, locale, strict) { - return createLocalOrUTC(input, format, locale, strict, false); - } - - var prototypeMin = deprecate( - 'moment().min is deprecated, use moment.max instead. http://momentjs.com/guides/#/warnings/min-max/', - function() { - var other = createLocal.apply(null, arguments); - if (this.isValid() && other.isValid()) { - return other < this ? this : other; - } else { - return createInvalid(); - } - } - ), - prototypeMax = deprecate( - 'moment().max is deprecated, use moment.min instead. http://momentjs.com/guides/#/warnings/min-max/', - function() { - var other = createLocal.apply(null, arguments); - if (this.isValid() && other.isValid()) { - return other > this ? this : other; - } else { - return createInvalid(); - } - } - ); - - // Pick a moment m from moments so that m[fn](other) is true for all - // other. This relies on the function fn to be transitive. - // - // moments should either be an array of moment objects or an array, whose - // first element is an array of moment objects. - function pickBy(fn, moments) { - var res, i; - if (moments.length === 1 && isArray(moments[0])) { - moments = moments[0]; - } - if (!moments.length) { - return createLocal(); - } - res = moments[0]; - for (i = 1; i < moments.length; ++i) { - if (!moments[i].isValid() || moments[i][fn](res)) { - res = moments[i]; - } - } - return res; - } - - // TODO: Use [].sort instead? - function min() { - var args = [].slice.call(arguments, 0); - - return pickBy('isBefore', args); - } - - function max() { - var args = [].slice.call(arguments, 0); - - return pickBy('isAfter', args); - } - - var now = function() { - return Date.now ? Date.now() : +new Date(); - }; - - var ordering = [ - 'year', - 'quarter', - 'month', - 'week', - 'day', - 'hour', - 'minute', - 'second', - 'millisecond', - ]; - - function isDurationValid(m) { - var key, - unitHasDecimal = false, - i, - orderLen = ordering.length; - for (key in m) { - if ( - hasOwnProp(m, key) && - !( - indexOf.call(ordering, key) !== -1 && - (m[key] == null || !isNaN(m[key])) - ) - ) { - return false; - } - } - - for (i = 0; i < orderLen; ++i) { - if (m[ordering[i]]) { - if (unitHasDecimal) { - return false; // only allow non-integers for smallest unit - } - if (parseFloat(m[ordering[i]]) !== toInt(m[ordering[i]])) { - unitHasDecimal = true; - } - } - } - - return true; - } - - function isValid$1() { - return this._isValid; - } - - function createInvalid$1() { - return createDuration(NaN); - } - - function Duration(duration) { - var normalizedInput = normalizeObjectUnits(duration), - years = normalizedInput.year || 0, - quarters = normalizedInput.quarter || 0, - months = normalizedInput.month || 0, - weeks = normalizedInput.week || normalizedInput.isoWeek || 0, - days = normalizedInput.day || 0, - hours = normalizedInput.hour || 0, - minutes = normalizedInput.minute || 0, - seconds = normalizedInput.second || 0, - milliseconds = normalizedInput.millisecond || 0; - - this._isValid = isDurationValid(normalizedInput); - - // representation for dateAddRemove - this._milliseconds = +milliseconds + - seconds * 1e3 + // 1000 - minutes * 6e4 + // 1000 * 60 - hours * 1000 * 60 * 60; //using 1000 * 60 * 60 instead of 36e5 to avoid floating point rounding errors https://github.com/moment/moment/issues/2978 - // Because of dateAddRemove treats 24 hours as different from a - // day when working around DST, we need to store them separately - this._days = +days + weeks * 7; - // It is impossible to translate months into days without knowing - // which months you are are talking about, so we have to store - // it separately. - this._months = +months + quarters * 3 + years * 12; - - this._data = {}; - - this._locale = getLocale(); - - this._bubble(); - } - - function isDuration(obj) { - return obj instanceof Duration; - } - - function absRound(number) { - if (number < 0) { - return Math.round(-1 * number) * -1; - } else { - return Math.round(number); - } - } - - // compare two arrays, return the number of differences - function compareArrays(array1, array2, dontConvert) { - var len = Math.min(array1.length, array2.length), - lengthDiff = Math.abs(array1.length - array2.length), - diffs = 0, - i; - for (i = 0; i < len; i++) { - if ( - (dontConvert && array1[i] !== array2[i]) || - (!dontConvert && toInt(array1[i]) !== toInt(array2[i])) - ) { - diffs++; - } - } - return diffs + lengthDiff; - } - - // FORMATTING - - function offset(token, separator) { - addFormatToken(token, 0, 0, function() { - var offset = this.utcOffset(), - sign = '+'; - if (offset < 0) { - offset = -offset; - sign = '-'; - } - return ( - sign + - zeroFill(~~(offset / 60), 2) + - separator + - zeroFill(~~offset % 60, 2) - ); - }); - } - - offset('Z', ':'); - offset('ZZ', ''); - - // PARSING - - addRegexToken('Z', matchShortOffset); - addRegexToken('ZZ', matchShortOffset); - addParseToken(['Z', 'ZZ'], function(input, array, config) { - config._useUTC = true; - config._tzm = offsetFromString(matchShortOffset, input); - }); - - // HELPERS - - // timezone chunker - // '+10:00' > ['10', '00'] - // '-1530' > ['-15', '30'] - var chunkOffset = /([\+\-]|\d\d)/gi; - - function offsetFromString(matcher, string) { - var matches = (string || '').match(matcher), - chunk, - parts, - minutes; - - if (matches === null) { - return null; - } - - chunk = matches[matches.length - 1] || []; - parts = (chunk + '').match(chunkOffset) || ['-', 0, 0]; - minutes = +(parts[1] * 60) + toInt(parts[2]); - - return minutes === 0 ? 0 : parts[0] === '+' ? minutes : -minutes; - } - - // Return a moment from input, that is local/utc/zone equivalent to model. - function cloneWithOffset(input, model) { - var res, diff; - if (model._isUTC) { - res = model.clone(); - diff = - (isMoment(input) || isDate(input) ? - input.valueOf() : - createLocal(input).valueOf()) - res.valueOf(); - // Use low-level api, because this fn is low-level api. - res._d.setTime(res._d.valueOf() + diff); - hooks.updateOffset(res, false); - return res; - } else { - return createLocal(input).local(); - } - } - - function getDateOffset(m) { - // On Firefox.24 Date#getTimezoneOffset returns a floating point. - // https://github.com/moment/moment/pull/1871 - return -Math.round(m._d.getTimezoneOffset()); - } - - // HOOKS - - // This function will be called whenever a moment is mutated. - // It is intended to keep the offset in sync with the timezone. - hooks.updateOffset = function() {}; - - // MOMENTS - - // keepLocalTime = true means only change the timezone, without - // affecting the local hour. So 5:31:26 +0300 --[utcOffset(2, true)]--> - // 5:31:26 +0200 It is possible that 5:31:26 doesn't exist with offset - // +0200, so we adjust the time as needed, to be valid. - // - // Keeping the time actually adds/subtracts (one hour) - // from the actual represented time. That is why we call updateOffset - // a second time. In case it wants us to change the offset again - // _changeInProgress == true case, then we have to adjust, because - // there is no such time in the given timezone. - function getSetOffset(input, keepLocalTime, keepMinutes) { - var offset = this._offset || 0, - localAdjust; - if (!this.isValid()) { - return input != null ? this : NaN; - } - if (input != null) { - if (typeof input === 'string') { - input = offsetFromString(matchShortOffset, input); - if (input === null) { - return this; - } - } else if (Math.abs(input) < 16 && !keepMinutes) { - input = input * 60; - } - if (!this._isUTC && keepLocalTime) { - localAdjust = getDateOffset(this); - } - this._offset = input; - this._isUTC = true; - if (localAdjust != null) { - this.add(localAdjust, 'm'); - } - if (offset !== input) { - if (!keepLocalTime || this._changeInProgress) { - addSubtract( - this, - createDuration(input - offset, 'm'), - 1, - false - ); - } else if (!this._changeInProgress) { - this._changeInProgress = true; - hooks.updateOffset(this, true); - this._changeInProgress = null; - } - } - return this; - } else { - return this._isUTC ? offset : getDateOffset(this); - } - } - - function getSetZone(input, keepLocalTime) { - if (input != null) { - if (typeof input !== 'string') { - input = -input; - } - - this.utcOffset(input, keepLocalTime); - - return this; - } else { - return -this.utcOffset(); - } - } - - function setOffsetToUTC(keepLocalTime) { - return this.utcOffset(0, keepLocalTime); - } - - function setOffsetToLocal(keepLocalTime) { - if (this._isUTC) { - this.utcOffset(0, keepLocalTime); - this._isUTC = false; - - if (keepLocalTime) { - this.subtract(getDateOffset(this), 'm'); - } - } - return this; - } - - function setOffsetToParsedOffset() { - if (this._tzm != null) { - this.utcOffset(this._tzm, false, true); - } else if (typeof this._i === 'string') { - var tZone = offsetFromString(matchOffset, this._i); - if (tZone != null) { - this.utcOffset(tZone); - } else { - this.utcOffset(0, true); - } - } - return this; - } - - function hasAlignedHourOffset(input) { - if (!this.isValid()) { - return false; - } - input = input ? createLocal(input).utcOffset() : 0; - - return (this.utcOffset() - input) % 60 === 0; - } - - function isDaylightSavingTime() { - return ( - this.utcOffset() > this.clone().month(0).utcOffset() || - this.utcOffset() > this.clone().month(5).utcOffset() - ); - } - - function isDaylightSavingTimeShifted() { - if (!isUndefined(this._isDSTShifted)) { - return this._isDSTShifted; - } - - var c = {}, - other; - - copyConfig(c, this); - c = prepareConfig(c); - - if (c._a) { - other = c._isUTC ? createUTC(c._a) : createLocal(c._a); - this._isDSTShifted = - this.isValid() && compareArrays(c._a, other.toArray()) > 0; - } else { - this._isDSTShifted = false; - } - - return this._isDSTShifted; - } - - function isLocal() { - return this.isValid() ? !this._isUTC : false; - } - - function isUtcOffset() { - return this.isValid() ? this._isUTC : false; - } - - function isUtc() { - return this.isValid() ? this._isUTC && this._offset === 0 : false; - } - - // ASP.NET json date format regex - var aspNetRegex = /^(-|\+)?(?:(\d*)[. ])?(\d+):(\d+)(?::(\d+)(\.\d*)?)?$/, - // from http://docs.closure-library.googlecode.com/git/closure_goog_date_date.js.source.html - // somewhat more in line with 4.4.3.2 2004 spec, but allows decimal anywhere - // and further modified to allow for strings containing both week and day - isoRegex = - /^(-|\+)?P(?:([-+]?[0-9,.]*)Y)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)W)?(?:([-+]?[0-9,.]*)D)?(?:T(?:([-+]?[0-9,.]*)H)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)S)?)?$/; - - function createDuration(input, key) { - var duration = input, - // matching against regexp is expensive, do it on demand - match = null, - sign, - ret, - diffRes; - - if (isDuration(input)) { - duration = { - ms: input._milliseconds, - d: input._days, - M: input._months, - }; - } else if (isNumber(input) || !isNaN(+input)) { - duration = {}; - if (key) { - duration[key] = +input; - } else { - duration.milliseconds = +input; - } - } else if ((match = aspNetRegex.exec(input))) { - sign = match[1] === '-' ? -1 : 1; - duration = { - y: 0, - d: toInt(match[DATE]) * sign, - h: toInt(match[HOUR]) * sign, - m: toInt(match[MINUTE]) * sign, - s: toInt(match[SECOND]) * sign, - ms: toInt(absRound(match[MILLISECOND] * 1000)) * sign, // the millisecond decimal point is included in the match - }; - } else if ((match = isoRegex.exec(input))) { - sign = match[1] === '-' ? -1 : 1; - duration = { - y: parseIso(match[2], sign), - M: parseIso(match[3], sign), - w: parseIso(match[4], sign), - d: parseIso(match[5], sign), - h: parseIso(match[6], sign), - m: parseIso(match[7], sign), - s: parseIso(match[8], sign), - }; - } else if (duration == null) { - // checks for null or undefined - duration = {}; - } else if ( - typeof duration === 'object' && - ('from' in duration || 'to' in duration) - ) { - diffRes = momentsDifference( - createLocal(duration.from), - createLocal(duration.to) - ); - - duration = {}; - duration.ms = diffRes.milliseconds; - duration.M = diffRes.months; - } - - ret = new Duration(duration); - - if (isDuration(input) && hasOwnProp(input, '_locale')) { - ret._locale = input._locale; - } - - if (isDuration(input) && hasOwnProp(input, '_isValid')) { - ret._isValid = input._isValid; - } - - return ret; - } - - createDuration.fn = Duration.prototype; - createDuration.invalid = createInvalid$1; - - function parseIso(inp, sign) { - // We'd normally use ~~inp for this, but unfortunately it also - // converts floats to ints. - // inp may be undefined, so careful calling replace on it. - var res = inp && parseFloat(inp.replace(',', '.')); - // apply sign while we're at it - return (isNaN(res) ? 0 : res) * sign; - } - - function positiveMomentsDifference(base, other) { - var res = {}; - - res.months = - other.month() - base.month() + (other.year() - base.year()) * 12; - if (base.clone().add(res.months, 'M').isAfter(other)) { - --res.months; - } - - res.milliseconds = +other - +base.clone().add(res.months, 'M'); - - return res; - } - - function momentsDifference(base, other) { - var res; - if (!(base.isValid() && other.isValid())) { - return { - milliseconds: 0, - months: 0 - }; - } - - other = cloneWithOffset(other, base); - if (base.isBefore(other)) { - res = positiveMomentsDifference(base, other); - } else { - res = positiveMomentsDifference(other, base); - res.milliseconds = -res.milliseconds; - res.months = -res.months; - } - - return res; - } - - // TODO: remove 'name' arg after deprecation is removed - function createAdder(direction, name) { - return function(val, period) { - var dur, tmp; - //invert the arguments, but complain about it - if (period !== null && !isNaN(+period)) { - deprecateSimple( - name, - 'moment().' + - name + - '(period, number) is deprecated. Please use moment().' + - name + - '(number, period). ' + - 'See http://momentjs.com/guides/#/warnings/add-inverted-param/ for more info.' - ); - tmp = val; - val = period; - period = tmp; - } - - dur = createDuration(val, period); - addSubtract(this, dur, direction); - return this; - }; - } - - function addSubtract(mom, duration, isAdding, updateOffset) { - var milliseconds = duration._milliseconds, - days = absRound(duration._days), - months = absRound(duration._months); - - if (!mom.isValid()) { - // No op - return; - } - - updateOffset = updateOffset == null ? true : updateOffset; - - if (months) { - setMonth(mom, get(mom, 'Month') + months * isAdding); - } - if (days) { - set$1(mom, 'Date', get(mom, 'Date') + days * isAdding); - } - if (milliseconds) { - mom._d.setTime(mom._d.valueOf() + milliseconds * isAdding); - } - if (updateOffset) { - hooks.updateOffset(mom, days || months); - } - } - - var add = createAdder(1, 'add'), - subtract = createAdder(-1, 'subtract'); - - function isString(input) { - return typeof input === 'string' || input instanceof String; - } - - // type MomentInput = Moment | Date | string | number | (number | string)[] | MomentInputObject | void; // null | undefined - function isMomentInput(input) { - return ( - isMoment(input) || - isDate(input) || - isString(input) || - isNumber(input) || - isNumberOrStringArray(input) || - isMomentInputObject(input) || - input === null || - input === undefined - ); - } - - function isMomentInputObject(input) { - var objectTest = isObject(input) && !isObjectEmpty(input), - propertyTest = false, - properties = [ - 'years', - 'year', - 'y', - 'months', - 'month', - 'M', - 'days', - 'day', - 'd', - 'dates', - 'date', - 'D', - 'hours', - 'hour', - 'h', - 'minutes', - 'minute', - 'm', - 'seconds', - 'second', - 's', - 'milliseconds', - 'millisecond', - 'ms', - ], - i, - property, - propertyLen = properties.length; - - for (i = 0; i < propertyLen; i += 1) { - property = properties[i]; - propertyTest = propertyTest || hasOwnProp(input, property); - } - - return objectTest && propertyTest; - } - - function isNumberOrStringArray(input) { - var arrayTest = isArray(input), - dataTypeTest = false; - if (arrayTest) { - dataTypeTest = - input.filter(function(item) { - return !isNumber(item) && isString(input); - }).length === 0; - } - return arrayTest && dataTypeTest; - } - - function isCalendarSpec(input) { - var objectTest = isObject(input) && !isObjectEmpty(input), - propertyTest = false, - properties = [ - 'sameDay', - 'nextDay', - 'lastDay', - 'nextWeek', - 'lastWeek', - 'sameElse', - ], - i, - property; - - for (i = 0; i < properties.length; i += 1) { - property = properties[i]; - propertyTest = propertyTest || hasOwnProp(input, property); - } - - return objectTest && propertyTest; - } - - function getCalendarFormat(myMoment, now) { - var diff = myMoment.diff(now, 'days', true); - return diff < -6 ? - 'sameElse' : - diff < -1 ? - 'lastWeek' : - diff < 0 ? - 'lastDay' : - diff < 1 ? - 'sameDay' : - diff < 2 ? - 'nextDay' : - diff < 7 ? - 'nextWeek' : - 'sameElse'; - } - - function calendar$1(time, formats) { - // Support for single parameter, formats only overload to the calendar function - if (arguments.length === 1) { - if (!arguments[0]) { - time = undefined; - formats = undefined; - } else if (isMomentInput(arguments[0])) { - time = arguments[0]; - formats = undefined; - } else if (isCalendarSpec(arguments[0])) { - formats = arguments[0]; - time = undefined; - } - } - // We want to compare the start of today, vs this. - // Getting start-of-today depends on whether we're local/utc/offset or not. - var now = time || createLocal(), - sod = cloneWithOffset(now, this).startOf('day'), - format = hooks.calendarFormat(this, sod) || 'sameElse', - output = - formats && - (isFunction(formats[format]) ? - formats[format].call(this, now) : - formats[format]); - - return this.format( - output || this.localeData().calendar(format, this, createLocal(now)) - ); - } - - function clone() { - return new Moment(this); - } - - function isAfter(input, units) { - var localInput = isMoment(input) ? input : createLocal(input); - if (!(this.isValid() && localInput.isValid())) { - return false; - } - units = normalizeUnits(units) || 'millisecond'; - if (units === 'millisecond') { - return this.valueOf() > localInput.valueOf(); - } else { - return localInput.valueOf() < this.clone().startOf(units).valueOf(); - } - } - - function isBefore(input, units) { - var localInput = isMoment(input) ? input : createLocal(input); - if (!(this.isValid() && localInput.isValid())) { - return false; - } - units = normalizeUnits(units) || 'millisecond'; - if (units === 'millisecond') { - return this.valueOf() < localInput.valueOf(); - } else { - return this.clone().endOf(units).valueOf() < localInput.valueOf(); - } - } - - function isBetween(from, to, units, inclusivity) { - var localFrom = isMoment(from) ? from : createLocal(from), - localTo = isMoment(to) ? to : createLocal(to); - if (!(this.isValid() && localFrom.isValid() && localTo.isValid())) { - return false; - } - inclusivity = inclusivity || '()'; - return ( - (inclusivity[0] === '(' ? - this.isAfter(localFrom, units) : - !this.isBefore(localFrom, units)) && - (inclusivity[1] === ')' ? - this.isBefore(localTo, units) : - !this.isAfter(localTo, units)) - ); - } - - function isSame(input, units) { - var localInput = isMoment(input) ? input : createLocal(input), - inputMs; - if (!(this.isValid() && localInput.isValid())) { - return false; - } - units = normalizeUnits(units) || 'millisecond'; - if (units === 'millisecond') { - return this.valueOf() === localInput.valueOf(); - } else { - inputMs = localInput.valueOf(); - return ( - this.clone().startOf(units).valueOf() <= inputMs && - inputMs <= this.clone().endOf(units).valueOf() - ); - } - } - - function isSameOrAfter(input, units) { - return this.isSame(input, units) || this.isAfter(input, units); - } - - function isSameOrBefore(input, units) { - return this.isSame(input, units) || this.isBefore(input, units); - } - - function diff(input, units, asFloat) { - var that, zoneDelta, output; - - if (!this.isValid()) { - return NaN; - } - - that = cloneWithOffset(input, this); - - if (!that.isValid()) { - return NaN; - } - - zoneDelta = (that.utcOffset() - this.utcOffset()) * 6e4; - - units = normalizeUnits(units); - - switch (units) { - case 'year': - output = monthDiff(this, that) / 12; - break; - case 'month': - output = monthDiff(this, that); - break; - case 'quarter': - output = monthDiff(this, that) / 3; - break; - case 'second': - output = (this - that) / 1e3; - break; // 1000 - case 'minute': - output = (this - that) / 6e4; - break; // 1000 * 60 - case 'hour': - output = (this - that) / 36e5; - break; // 1000 * 60 * 60 - case 'day': - output = (this - that - zoneDelta) / 864e5; - break; // 1000 * 60 * 60 * 24, negate dst - case 'week': - output = (this - that - zoneDelta) / 6048e5; - break; // 1000 * 60 * 60 * 24 * 7, negate dst - default: - output = this - that; - } - - return asFloat ? output : absFloor(output); - } - - function monthDiff(a, b) { - if (a.date() < b.date()) { - // end-of-month calculations work correct when the start month has more - // days than the end month. - return -monthDiff(b, a); - } - // difference in months - var wholeMonthDiff = (b.year() - a.year()) * 12 + (b.month() - a.month()), - // b is in (anchor - 1 month, anchor + 1 month) - anchor = a.clone().add(wholeMonthDiff, 'months'), - anchor2, - adjust; - - if (b - anchor < 0) { - anchor2 = a.clone().add(wholeMonthDiff - 1, 'months'); - // linear across the month - adjust = (b - anchor) / (anchor - anchor2); - } else { - anchor2 = a.clone().add(wholeMonthDiff + 1, 'months'); - // linear across the month - adjust = (b - anchor) / (anchor2 - anchor); - } - - //check for negative zero, return zero if negative zero - return -(wholeMonthDiff + adjust) || 0; - } - - hooks.defaultFormat = 'YYYY-MM-DDTHH:mm:ssZ'; - hooks.defaultFormatUtc = 'YYYY-MM-DDTHH:mm:ss[Z]'; - - function toString() { - return this.clone().locale('en').format('ddd MMM DD YYYY HH:mm:ss [GMT]ZZ'); - } - - function toISOString(keepOffset) { - if (!this.isValid()) { - return null; - } - var utc = keepOffset !== true, - m = utc ? this.clone().utc() : this; - if (m.year() < 0 || m.year() > 9999) { - return formatMoment( - m, - utc ? - 'YYYYYY-MM-DD[T]HH:mm:ss.SSS[Z]' : - 'YYYYYY-MM-DD[T]HH:mm:ss.SSSZ' - ); - } - if (isFunction(Date.prototype.toISOString)) { - // native implementation is ~50x faster, use it when we can - if (utc) { - return this.toDate().toISOString(); - } else { - return new Date(this.valueOf() + this.utcOffset() * 60 * 1000) - .toISOString() - .replace('Z', formatMoment(m, 'Z')); - } - } - return formatMoment( - m, - utc ? 'YYYY-MM-DD[T]HH:mm:ss.SSS[Z]' : 'YYYY-MM-DD[T]HH:mm:ss.SSSZ' - ); - } - - /** - * Return a human readable representation of a moment that can - * also be evaluated to get a new moment which is the same - * - * @link https://nodejs.org/dist/latest/docs/api/util.html#util_custom_inspect_function_on_objects - */ - function inspect() { - if (!this.isValid()) { - return 'moment.invalid(/* ' + this._i + ' */)'; - } - var func = 'moment', - zone = '', - prefix, - year, - datetime, - suffix; - if (!this.isLocal()) { - func = this.utcOffset() === 0 ? 'moment.utc' : 'moment.parseZone'; - zone = 'Z'; - } - prefix = '[' + func + '("]'; - year = 0 <= this.year() && this.year() <= 9999 ? 'YYYY' : 'YYYYYY'; - datetime = '-MM-DD[T]HH:mm:ss.SSS'; - suffix = zone + '[")]'; - - return this.format(prefix + year + datetime + suffix); - } - - function format(inputString) { - if (!inputString) { - inputString = this.isUtc() ? - hooks.defaultFormatUtc : - hooks.defaultFormat; - } - var output = formatMoment(this, inputString); - return this.localeData().postformat(output); - } - - function from(time, withoutSuffix) { - if ( - this.isValid() && - ((isMoment(time) && time.isValid()) || createLocal(time).isValid()) - ) { - return createDuration({ - to: this, - from: time - }) - .locale(this.locale()) - .humanize(!withoutSuffix); - } else { - return this.localeData().invalidDate(); - } - } - - function fromNow(withoutSuffix) { - return this.from(createLocal(), withoutSuffix); - } - - function to(time, withoutSuffix) { - if ( - this.isValid() && - ((isMoment(time) && time.isValid()) || createLocal(time).isValid()) - ) { - return createDuration({ - from: this, - to: time - }) - .locale(this.locale()) - .humanize(!withoutSuffix); - } else { - return this.localeData().invalidDate(); - } - } - - function toNow(withoutSuffix) { - return this.to(createLocal(), withoutSuffix); - } - - // If passed a locale key, it will set the locale for this - // instance. Otherwise, it will return the locale configuration - // variables for this instance. - function locale(key) { - var newLocaleData; - - if (key === undefined) { - return this._locale._abbr; - } else { - newLocaleData = getLocale(key); - if (newLocaleData != null) { - this._locale = newLocaleData; - } - return this; - } - } - - var lang = deprecate( - 'moment().lang() is deprecated. Instead, use moment().localeData() to get the language configuration. Use moment().locale() to change languages.', - function(key) { - if (key === undefined) { - return this.localeData(); - } else { - return this.locale(key); - } - } - ); - - function localeData() { - return this._locale; - } - - var MS_PER_SECOND = 1000, - MS_PER_MINUTE = 60 * MS_PER_SECOND, - MS_PER_HOUR = 60 * MS_PER_MINUTE, - MS_PER_400_YEARS = (365 * 400 + 97) * 24 * MS_PER_HOUR; - - // actual modulo - handles negative numbers (for dates before 1970): - function mod$1(dividend, divisor) { - return ((dividend % divisor) + divisor) % divisor; - } - - function localStartOfDate(y, m, d) { - // the date constructor remaps years 0-99 to 1900-1999 - if (y < 100 && y >= 0) { - // preserve leap years using a full 400 year cycle, then reset - return new Date(y + 400, m, d) - MS_PER_400_YEARS; - } else { - return new Date(y, m, d).valueOf(); - } - } - - function utcStartOfDate(y, m, d) { - // Date.UTC remaps years 0-99 to 1900-1999 - if (y < 100 && y >= 0) { - // preserve leap years using a full 400 year cycle, then reset - return Date.UTC(y + 400, m, d) - MS_PER_400_YEARS; - } else { - return Date.UTC(y, m, d); - } - } - - function startOf(units) { - var time, startOfDate; - units = normalizeUnits(units); - if (units === undefined || units === 'millisecond' || !this.isValid()) { - return this; - } - - startOfDate = this._isUTC ? utcStartOfDate : localStartOfDate; - - switch (units) { - case 'year': - time = startOfDate(this.year(), 0, 1); - break; - case 'quarter': - time = startOfDate( - this.year(), - this.month() - (this.month() % 3), - 1 - ); - break; - case 'month': - time = startOfDate(this.year(), this.month(), 1); - break; - case 'week': - time = startOfDate( - this.year(), - this.month(), - this.date() - this.weekday() - ); - break; - case 'isoWeek': - time = startOfDate( - this.year(), - this.month(), - this.date() - (this.isoWeekday() - 1) - ); - break; - case 'day': - case 'date': - time = startOfDate(this.year(), this.month(), this.date()); - break; - case 'hour': - time = this._d.valueOf(); - time -= mod$1( - time + (this._isUTC ? 0 : this.utcOffset() * MS_PER_MINUTE), - MS_PER_HOUR - ); - break; - case 'minute': - time = this._d.valueOf(); - time -= mod$1(time, MS_PER_MINUTE); - break; - case 'second': - time = this._d.valueOf(); - time -= mod$1(time, MS_PER_SECOND); - break; - } - - this._d.setTime(time); - hooks.updateOffset(this, true); - return this; - } - - function endOf(units) { - var time, startOfDate; - units = normalizeUnits(units); - if (units === undefined || units === 'millisecond' || !this.isValid()) { - return this; - } - - startOfDate = this._isUTC ? utcStartOfDate : localStartOfDate; - - switch (units) { - case 'year': - time = startOfDate(this.year() + 1, 0, 1) - 1; - break; - case 'quarter': - time = - startOfDate( - this.year(), - this.month() - (this.month() % 3) + 3, - 1 - ) - 1; - break; - case 'month': - time = startOfDate(this.year(), this.month() + 1, 1) - 1; - break; - case 'week': - time = - startOfDate( - this.year(), - this.month(), - this.date() - this.weekday() + 7 - ) - 1; - break; - case 'isoWeek': - time = - startOfDate( - this.year(), - this.month(), - this.date() - (this.isoWeekday() - 1) + 7 - ) - 1; - break; - case 'day': - case 'date': - time = startOfDate(this.year(), this.month(), this.date() + 1) - 1; - break; - case 'hour': - time = this._d.valueOf(); - time += - MS_PER_HOUR - - mod$1( - time + (this._isUTC ? 0 : this.utcOffset() * MS_PER_MINUTE), - MS_PER_HOUR - ) - - 1; - break; - case 'minute': - time = this._d.valueOf(); - time += MS_PER_MINUTE - mod$1(time, MS_PER_MINUTE) - 1; - break; - case 'second': - time = this._d.valueOf(); - time += MS_PER_SECOND - mod$1(time, MS_PER_SECOND) - 1; - break; - } - - this._d.setTime(time); - hooks.updateOffset(this, true); - return this; - } - - function valueOf() { - return this._d.valueOf() - (this._offset || 0) * 60000; - } - - function unix() { - return Math.floor(this.valueOf() / 1000); - } - - function toDate() { - return new Date(this.valueOf()); - } - - function toArray() { - var m = this; - return [ - m.year(), - m.month(), - m.date(), - m.hour(), - m.minute(), - m.second(), - m.millisecond(), - ]; - } - - function toObject() { - var m = this; - return { - years: m.year(), - months: m.month(), - date: m.date(), - hours: m.hours(), - minutes: m.minutes(), - seconds: m.seconds(), - milliseconds: m.milliseconds(), - }; - } - - function toJSON() { - // new Date(NaN).toJSON() === null - return this.isValid() ? this.toISOString() : null; - } - - function isValid$2() { - return isValid(this); - } - - function parsingFlags() { - return extend({}, getParsingFlags(this)); - } - - function invalidAt() { - return getParsingFlags(this).overflow; - } - - function creationData() { - return { - input: this._i, - format: this._f, - locale: this._locale, - isUTC: this._isUTC, - strict: this._strict, - }; - } - - addFormatToken('N', 0, 0, 'eraAbbr'); - addFormatToken('NN', 0, 0, 'eraAbbr'); - addFormatToken('NNN', 0, 0, 'eraAbbr'); - addFormatToken('NNNN', 0, 0, 'eraName'); - addFormatToken('NNNNN', 0, 0, 'eraNarrow'); - - addFormatToken('y', ['y', 1], 'yo', 'eraYear'); - addFormatToken('y', ['yy', 2], 0, 'eraYear'); - addFormatToken('y', ['yyy', 3], 0, 'eraYear'); - addFormatToken('y', ['yyyy', 4], 0, 'eraYear'); - - addRegexToken('N', matchEraAbbr); - addRegexToken('NN', matchEraAbbr); - addRegexToken('NNN', matchEraAbbr); - addRegexToken('NNNN', matchEraName); - addRegexToken('NNNNN', matchEraNarrow); - - addParseToken( - ['N', 'NN', 'NNN', 'NNNN', 'NNNNN'], - function(input, array, config, token) { - var era = config._locale.erasParse(input, token, config._strict); - if (era) { - getParsingFlags(config).era = era; - } else { - getParsingFlags(config).invalidEra = input; - } - } - ); - - addRegexToken('y', matchUnsigned); - addRegexToken('yy', matchUnsigned); - addRegexToken('yyy', matchUnsigned); - addRegexToken('yyyy', matchUnsigned); - addRegexToken('yo', matchEraYearOrdinal); - - addParseToken(['y', 'yy', 'yyy', 'yyyy'], YEAR); - addParseToken(['yo'], function(input, array, config, token) { - var match; - if (config._locale._eraYearOrdinalRegex) { - match = input.match(config._locale._eraYearOrdinalRegex); - } - - if (config._locale.eraYearOrdinalParse) { - array[YEAR] = config._locale.eraYearOrdinalParse(input, match); - } else { - array[YEAR] = parseInt(input, 10); - } - }); - - function localeEras(m, format) { - var i, - l, - date, - eras = this._eras || getLocale('en')._eras; - for (i = 0, l = eras.length; i < l; ++i) { - switch (typeof eras[i].since) { - case 'string': - // truncate time - date = hooks(eras[i].since).startOf('day'); - eras[i].since = date.valueOf(); - break; - } - - switch (typeof eras[i].until) { - case 'undefined': - eras[i].until = +Infinity; - break; - case 'string': - // truncate time - date = hooks(eras[i].until).startOf('day').valueOf(); - eras[i].until = date.valueOf(); - break; - } - } - return eras; - } - - function localeErasParse(eraName, format, strict) { - var i, - l, - eras = this.eras(), - name, - abbr, - narrow; - eraName = eraName.toUpperCase(); - - for (i = 0, l = eras.length; i < l; ++i) { - name = eras[i].name.toUpperCase(); - abbr = eras[i].abbr.toUpperCase(); - narrow = eras[i].narrow.toUpperCase(); - - if (strict) { - switch (format) { - case 'N': - case 'NN': - case 'NNN': - if (abbr === eraName) { - return eras[i]; - } - break; - - case 'NNNN': - if (name === eraName) { - return eras[i]; - } - break; - - case 'NNNNN': - if (narrow === eraName) { - return eras[i]; - } - break; - } - } else if ([name, abbr, narrow].indexOf(eraName) >= 0) { - return eras[i]; - } - } - } - - function localeErasConvertYear(era, year) { - var dir = era.since <= era.until ? +1 : -1; - if (year === undefined) { - return hooks(era.since).year(); - } else { - return hooks(era.since).year() + (year - era.offset) * dir; - } - } - - function getEraName() { - var i, - l, - val, - eras = this.localeData().eras(); - for (i = 0, l = eras.length; i < l; ++i) { - // truncate time - val = this.clone().startOf('day').valueOf(); - - if (eras[i].since <= val && val <= eras[i].until) { - return eras[i].name; - } - if (eras[i].until <= val && val <= eras[i].since) { - return eras[i].name; - } - } - - return ''; - } - - function getEraNarrow() { - var i, - l, - val, - eras = this.localeData().eras(); - for (i = 0, l = eras.length; i < l; ++i) { - // truncate time - val = this.clone().startOf('day').valueOf(); - - if (eras[i].since <= val && val <= eras[i].until) { - return eras[i].narrow; - } - if (eras[i].until <= val && val <= eras[i].since) { - return eras[i].narrow; - } - } - - return ''; - } - - function getEraAbbr() { - var i, - l, - val, - eras = this.localeData().eras(); - for (i = 0, l = eras.length; i < l; ++i) { - // truncate time - val = this.clone().startOf('day').valueOf(); - - if (eras[i].since <= val && val <= eras[i].until) { - return eras[i].abbr; - } - if (eras[i].until <= val && val <= eras[i].since) { - return eras[i].abbr; - } - } - - return ''; - } - - function getEraYear() { - var i, - l, - dir, - val, - eras = this.localeData().eras(); - for (i = 0, l = eras.length; i < l; ++i) { - dir = eras[i].since <= eras[i].until ? +1 : -1; - - // truncate time - val = this.clone().startOf('day').valueOf(); - - if ( - (eras[i].since <= val && val <= eras[i].until) || - (eras[i].until <= val && val <= eras[i].since) - ) { - return ( - (this.year() - hooks(eras[i].since).year()) * dir + - eras[i].offset - ); - } - } - - return this.year(); - } - - function erasNameRegex(isStrict) { - if (!hasOwnProp(this, '_erasNameRegex')) { - computeErasParse.call(this); - } - return isStrict ? this._erasNameRegex : this._erasRegex; - } - - function erasAbbrRegex(isStrict) { - if (!hasOwnProp(this, '_erasAbbrRegex')) { - computeErasParse.call(this); - } - return isStrict ? this._erasAbbrRegex : this._erasRegex; - } - - function erasNarrowRegex(isStrict) { - if (!hasOwnProp(this, '_erasNarrowRegex')) { - computeErasParse.call(this); - } - return isStrict ? this._erasNarrowRegex : this._erasRegex; - } - - function matchEraAbbr(isStrict, locale) { - return locale.erasAbbrRegex(isStrict); - } - - function matchEraName(isStrict, locale) { - return locale.erasNameRegex(isStrict); - } - - function matchEraNarrow(isStrict, locale) { - return locale.erasNarrowRegex(isStrict); - } - - function matchEraYearOrdinal(isStrict, locale) { - return locale._eraYearOrdinalRegex || matchUnsigned; - } - - function computeErasParse() { - var abbrPieces = [], - namePieces = [], - narrowPieces = [], - mixedPieces = [], - i, - l, - erasName, - erasAbbr, - erasNarrow, - eras = this.eras(); - - for (i = 0, l = eras.length; i < l; ++i) { - erasName = regexEscape(eras[i].name); - erasAbbr = regexEscape(eras[i].abbr); - erasNarrow = regexEscape(eras[i].narrow); - - namePieces.push(erasName); - abbrPieces.push(erasAbbr); - narrowPieces.push(erasNarrow); - mixedPieces.push(erasName); - mixedPieces.push(erasAbbr); - mixedPieces.push(erasNarrow); - } - - this._erasRegex = new RegExp('^(' + mixedPieces.join('|') + ')', 'i'); - this._erasNameRegex = new RegExp('^(' + namePieces.join('|') + ')', 'i'); - this._erasAbbrRegex = new RegExp('^(' + abbrPieces.join('|') + ')', 'i'); - this._erasNarrowRegex = new RegExp( - '^(' + narrowPieces.join('|') + ')', - 'i' - ); - } - - // FORMATTING - - addFormatToken(0, ['gg', 2], 0, function() { - return this.weekYear() % 100; - }); - - addFormatToken(0, ['GG', 2], 0, function() { - return this.isoWeekYear() % 100; - }); - - function addWeekYearFormatToken(token, getter) { - addFormatToken(0, [token, token.length], 0, getter); - } - - addWeekYearFormatToken('gggg', 'weekYear'); - addWeekYearFormatToken('ggggg', 'weekYear'); - addWeekYearFormatToken('GGGG', 'isoWeekYear'); - addWeekYearFormatToken('GGGGG', 'isoWeekYear'); - - // ALIASES - - // PARSING - - addRegexToken('G', matchSigned); - addRegexToken('g', matchSigned); - addRegexToken('GG', match1to2, match2); - addRegexToken('gg', match1to2, match2); - addRegexToken('GGGG', match1to4, match4); - addRegexToken('gggg', match1to4, match4); - addRegexToken('GGGGG', match1to6, match6); - addRegexToken('ggggg', match1to6, match6); - - addWeekParseToken( - ['gggg', 'ggggg', 'GGGG', 'GGGGG'], - function(input, week, config, token) { - week[token.substr(0, 2)] = toInt(input); - } - ); - - addWeekParseToken(['gg', 'GG'], function(input, week, config, token) { - week[token] = hooks.parseTwoDigitYear(input); - }); - - // MOMENTS - - function getSetWeekYear(input) { - return getSetWeekYearHelper.call( - this, - input, - this.week(), - this.weekday() + this.localeData()._week.dow, - this.localeData()._week.dow, - this.localeData()._week.doy - ); - } - - function getSetISOWeekYear(input) { - return getSetWeekYearHelper.call( - this, - input, - this.isoWeek(), - this.isoWeekday(), - 1, - 4 - ); - } - - function getISOWeeksInYear() { - return weeksInYear(this.year(), 1, 4); - } - - function getISOWeeksInISOWeekYear() { - return weeksInYear(this.isoWeekYear(), 1, 4); - } - - function getWeeksInYear() { - var weekInfo = this.localeData()._week; - return weeksInYear(this.year(), weekInfo.dow, weekInfo.doy); - } - - function getWeeksInWeekYear() { - var weekInfo = this.localeData()._week; - return weeksInYear(this.weekYear(), weekInfo.dow, weekInfo.doy); - } - - function getSetWeekYearHelper(input, week, weekday, dow, doy) { - var weeksTarget; - if (input == null) { - return weekOfYear(this, dow, doy).year; - } else { - weeksTarget = weeksInYear(input, dow, doy); - if (week > weeksTarget) { - week = weeksTarget; - } - return setWeekAll.call(this, input, week, weekday, dow, doy); - } - } - - function setWeekAll(weekYear, week, weekday, dow, doy) { - var dayOfYearData = dayOfYearFromWeeks(weekYear, week, weekday, dow, doy), - date = createUTCDate(dayOfYearData.year, 0, dayOfYearData.dayOfYear); - - this.year(date.getUTCFullYear()); - this.month(date.getUTCMonth()); - this.date(date.getUTCDate()); - return this; - } - - // FORMATTING - - addFormatToken('Q', 0, 'Qo', 'quarter'); - - // PARSING - - addRegexToken('Q', match1); - addParseToken('Q', function(input, array) { - array[MONTH] = (toInt(input) - 1) * 3; - }); - - // MOMENTS - - function getSetQuarter(input) { - return input == null ? - Math.ceil((this.month() + 1) / 3) : - this.month((input - 1) * 3 + (this.month() % 3)); - } - - // FORMATTING - - addFormatToken('D', ['DD', 2], 'Do', 'date'); - - // PARSING - - addRegexToken('D', match1to2, match1to2NoLeadingZero); - addRegexToken('DD', match1to2, match2); - addRegexToken('Do', function(isStrict, locale) { - // TODO: Remove "ordinalParse" fallback in next major release. - return isStrict ? - locale._dayOfMonthOrdinalParse || locale._ordinalParse : - locale._dayOfMonthOrdinalParseLenient; - }); - - addParseToken(['D', 'DD'], DATE); - addParseToken('Do', function(input, array) { - array[DATE] = toInt(input.match(match1to2)[0]); - }); - - // MOMENTS - - var getSetDayOfMonth = makeGetSet('Date', true); - - // FORMATTING - - addFormatToken('DDD', ['DDDD', 3], 'DDDo', 'dayOfYear'); - - // PARSING - - addRegexToken('DDD', match1to3); - addRegexToken('DDDD', match3); - addParseToken(['DDD', 'DDDD'], function(input, array, config) { - config._dayOfYear = toInt(input); - }); - - // HELPERS - - // MOMENTS - - function getSetDayOfYear(input) { - var dayOfYear = - Math.round( - (this.clone().startOf('day') - this.clone().startOf('year')) / 864e5 - ) + 1; - return input == null ? dayOfYear : this.add(input - dayOfYear, 'd'); - } - - // FORMATTING - - addFormatToken('m', ['mm', 2], 0, 'minute'); - - // PARSING - - addRegexToken('m', match1to2, match1to2HasZero); - addRegexToken('mm', match1to2, match2); - addParseToken(['m', 'mm'], MINUTE); - - // MOMENTS - - var getSetMinute = makeGetSet('Minutes', false); - - // FORMATTING - - addFormatToken('s', ['ss', 2], 0, 'second'); - - // PARSING - - addRegexToken('s', match1to2, match1to2HasZero); - addRegexToken('ss', match1to2, match2); - addParseToken(['s', 'ss'], SECOND); - - // MOMENTS - - var getSetSecond = makeGetSet('Seconds', false); - - // FORMATTING - - addFormatToken('S', 0, 0, function() { - return ~~(this.millisecond() / 100); - }); - - addFormatToken(0, ['SS', 2], 0, function() { - return ~~(this.millisecond() / 10); - }); - - addFormatToken(0, ['SSS', 3], 0, 'millisecond'); - addFormatToken(0, ['SSSS', 4], 0, function() { - return this.millisecond() * 10; - }); - addFormatToken(0, ['SSSSS', 5], 0, function() { - return this.millisecond() * 100; - }); - addFormatToken(0, ['SSSSSS', 6], 0, function() { - return this.millisecond() * 1000; - }); - addFormatToken(0, ['SSSSSSS', 7], 0, function() { - return this.millisecond() * 10000; - }); - addFormatToken(0, ['SSSSSSSS', 8], 0, function() { - return this.millisecond() * 100000; - }); - addFormatToken(0, ['SSSSSSSSS', 9], 0, function() { - return this.millisecond() * 1000000; - }); - - // PARSING - - addRegexToken('S', match1to3, match1); - addRegexToken('SS', match1to3, match2); - addRegexToken('SSS', match1to3, match3); - - var token, getSetMillisecond; - for (token = 'SSSS'; token.length <= 9; token += 'S') { - addRegexToken(token, matchUnsigned); - } - - function parseMs(input, array) { - array[MILLISECOND] = toInt(('0.' + input) * 1000); - } - - for (token = 'S'; token.length <= 9; token += 'S') { - addParseToken(token, parseMs); - } - - getSetMillisecond = makeGetSet('Milliseconds', false); - - // FORMATTING - - addFormatToken('z', 0, 0, 'zoneAbbr'); - addFormatToken('zz', 0, 0, 'zoneName'); - - // MOMENTS - - function getZoneAbbr() { - return this._isUTC ? 'UTC' : ''; - } - - function getZoneName() { - return this._isUTC ? 'Coordinated Universal Time' : ''; - } - - var proto = Moment.prototype; - - proto.add = add; - proto.calendar = calendar$1; - proto.clone = clone; - proto.diff = diff; - proto.endOf = endOf; - proto.format = format; - proto.from = from; - proto.fromNow = fromNow; - proto.to = to; - proto.toNow = toNow; - proto.get = stringGet; - proto.invalidAt = invalidAt; - proto.isAfter = isAfter; - proto.isBefore = isBefore; - proto.isBetween = isBetween; - proto.isSame = isSame; - proto.isSameOrAfter = isSameOrAfter; - proto.isSameOrBefore = isSameOrBefore; - proto.isValid = isValid$2; - proto.lang = lang; - proto.locale = locale; - proto.localeData = localeData; - proto.max = prototypeMax; - proto.min = prototypeMin; - proto.parsingFlags = parsingFlags; - proto.set = stringSet; - proto.startOf = startOf; - proto.subtract = subtract; - proto.toArray = toArray; - proto.toObject = toObject; - proto.toDate = toDate; - proto.toISOString = toISOString; - proto.inspect = inspect; - if (typeof Symbol !== 'undefined' && Symbol.for != null) { - proto[Symbol.for('nodejs.util.inspect.custom')] = function() { - return 'Moment<' + this.format() + '>'; - }; - } - proto.toJSON = toJSON; - proto.toString = toString; - proto.unix = unix; - proto.valueOf = valueOf; - proto.creationData = creationData; - proto.eraName = getEraName; - proto.eraNarrow = getEraNarrow; - proto.eraAbbr = getEraAbbr; - proto.eraYear = getEraYear; - proto.year = getSetYear; - proto.isLeapYear = getIsLeapYear; - proto.weekYear = getSetWeekYear; - proto.isoWeekYear = getSetISOWeekYear; - proto.quarter = proto.quarters = getSetQuarter; - proto.month = getSetMonth; - proto.daysInMonth = getDaysInMonth; - proto.week = proto.weeks = getSetWeek; - proto.isoWeek = proto.isoWeeks = getSetISOWeek; - proto.weeksInYear = getWeeksInYear; - proto.weeksInWeekYear = getWeeksInWeekYear; - proto.isoWeeksInYear = getISOWeeksInYear; - proto.isoWeeksInISOWeekYear = getISOWeeksInISOWeekYear; - proto.date = getSetDayOfMonth; - proto.day = proto.days = getSetDayOfWeek; - proto.weekday = getSetLocaleDayOfWeek; - proto.isoWeekday = getSetISODayOfWeek; - proto.dayOfYear = getSetDayOfYear; - proto.hour = proto.hours = getSetHour; - proto.minute = proto.minutes = getSetMinute; - proto.second = proto.seconds = getSetSecond; - proto.millisecond = proto.milliseconds = getSetMillisecond; - proto.utcOffset = getSetOffset; - proto.utc = setOffsetToUTC; - proto.local = setOffsetToLocal; - proto.parseZone = setOffsetToParsedOffset; - proto.hasAlignedHourOffset = hasAlignedHourOffset; - proto.isDST = isDaylightSavingTime; - proto.isLocal = isLocal; - proto.isUtcOffset = isUtcOffset; - proto.isUtc = isUtc; - proto.isUTC = isUtc; - proto.zoneAbbr = getZoneAbbr; - proto.zoneName = getZoneName; - proto.dates = deprecate( - 'dates accessor is deprecated. Use date instead.', - getSetDayOfMonth - ); - proto.months = deprecate( - 'months accessor is deprecated. Use month instead', - getSetMonth - ); - proto.years = deprecate( - 'years accessor is deprecated. Use year instead', - getSetYear - ); - proto.zone = deprecate( - 'moment().zone is deprecated, use moment().utcOffset instead. http://momentjs.com/guides/#/warnings/zone/', - getSetZone - ); - proto.isDSTShifted = deprecate( - 'isDSTShifted is deprecated. See http://momentjs.com/guides/#/warnings/dst-shifted/ for more information', - isDaylightSavingTimeShifted - ); - - function createUnix(input) { - return createLocal(input * 1000); - } - - function createInZone() { - return createLocal.apply(null, arguments).parseZone(); - } - - function preParsePostFormat(string) { - return string; - } - - var proto$1 = Locale.prototype; - - proto$1.calendar = calendar; - proto$1.longDateFormat = longDateFormat; - proto$1.invalidDate = invalidDate; - proto$1.ordinal = ordinal; - proto$1.preparse = preParsePostFormat; - proto$1.postformat = preParsePostFormat; - proto$1.relativeTime = relativeTime; - proto$1.pastFuture = pastFuture; - proto$1.set = set; - proto$1.eras = localeEras; - proto$1.erasParse = localeErasParse; - proto$1.erasConvertYear = localeErasConvertYear; - proto$1.erasAbbrRegex = erasAbbrRegex; - proto$1.erasNameRegex = erasNameRegex; - proto$1.erasNarrowRegex = erasNarrowRegex; - - proto$1.months = localeMonths; - proto$1.monthsShort = localeMonthsShort; - proto$1.monthsParse = localeMonthsParse; - proto$1.monthsRegex = monthsRegex; - proto$1.monthsShortRegex = monthsShortRegex; - proto$1.week = localeWeek; - proto$1.firstDayOfYear = localeFirstDayOfYear; - proto$1.firstDayOfWeek = localeFirstDayOfWeek; - - proto$1.weekdays = localeWeekdays; - proto$1.weekdaysMin = localeWeekdaysMin; - proto$1.weekdaysShort = localeWeekdaysShort; - proto$1.weekdaysParse = localeWeekdaysParse; - - proto$1.weekdaysRegex = weekdaysRegex; - proto$1.weekdaysShortRegex = weekdaysShortRegex; - proto$1.weekdaysMinRegex = weekdaysMinRegex; - - proto$1.isPM = localeIsPM; - proto$1.meridiem = localeMeridiem; - - function get$1(format, index, field, setter) { - var locale = getLocale(), - utc = createUTC().set(setter, index); - return locale[field](utc, format); - } - - function listMonthsImpl(format, index, field) { - if (isNumber(format)) { - index = format; - format = undefined; - } - - format = format || ''; - - if (index != null) { - return get$1(format, index, field, 'month'); - } - - var i, - out = []; - for (i = 0; i < 12; i++) { - out[i] = get$1(format, i, field, 'month'); - } - return out; - } - - // () - // (5) - // (fmt, 5) - // (fmt) - // (true) - // (true, 5) - // (true, fmt, 5) - // (true, fmt) - function listWeekdaysImpl(localeSorted, format, index, field) { - if (typeof localeSorted === 'boolean') { - if (isNumber(format)) { - index = format; - format = undefined; - } - - format = format || ''; - } else { - format = localeSorted; - index = format; - localeSorted = false; - - if (isNumber(format)) { - index = format; - format = undefined; - } - - format = format || ''; - } - - var locale = getLocale(), - shift = localeSorted ? locale._week.dow : 0, - i, - out = []; - - if (index != null) { - return get$1(format, (index + shift) % 7, field, 'day'); - } - - for (i = 0; i < 7; i++) { - out[i] = get$1(format, (i + shift) % 7, field, 'day'); - } - return out; - } - - function listMonths(format, index) { - return listMonthsImpl(format, index, 'months'); - } - - function listMonthsShort(format, index) { - return listMonthsImpl(format, index, 'monthsShort'); - } - - function listWeekdays(localeSorted, format, index) { - return listWeekdaysImpl(localeSorted, format, index, 'weekdays'); - } - - function listWeekdaysShort(localeSorted, format, index) { - return listWeekdaysImpl(localeSorted, format, index, 'weekdaysShort'); - } - - function listWeekdaysMin(localeSorted, format, index) { - return listWeekdaysImpl(localeSorted, format, index, 'weekdaysMin'); - } - - getSetGlobalLocale('en', { - eras: [{ - since: '0001-01-01', - until: +Infinity, - offset: 1, - name: 'Anno Domini', - narrow: 'AD', - abbr: 'AD', - }, { - since: '0000-12-31', - until: -Infinity, - offset: 1, - name: 'Before Christ', - narrow: 'BC', - abbr: 'BC', - }, ], - dayOfMonthOrdinalParse: /\d{1,2}(th|st|nd|rd)/, - ordinal: function(number) { - var b = number % 10, - output = - toInt((number % 100) / 10) === 1 ? - 'th' : - b === 1 ? - 'st' : - b === 2 ? - 'nd' : - b === 3 ? - 'rd' : - 'th'; - return number + output; - }, - }); - - // Side effect imports - - hooks.lang = deprecate( - 'moment.lang is deprecated. Use moment.locale instead.', - getSetGlobalLocale - ); - hooks.langData = deprecate( - 'moment.langData is deprecated. Use moment.localeData instead.', - getLocale - ); - - var mathAbs = Math.abs; - - function abs() { - var data = this._data; - - this._milliseconds = mathAbs(this._milliseconds); - this._days = mathAbs(this._days); - this._months = mathAbs(this._months); - - data.milliseconds = mathAbs(data.milliseconds); - data.seconds = mathAbs(data.seconds); - data.minutes = mathAbs(data.minutes); - data.hours = mathAbs(data.hours); - data.months = mathAbs(data.months); - data.years = mathAbs(data.years); - - return this; - } - - function addSubtract$1(duration, input, value, direction) { - var other = createDuration(input, value); - - duration._milliseconds += direction * other._milliseconds; - duration._days += direction * other._days; - duration._months += direction * other._months; - - return duration._bubble(); - } - - // supports only 2.0-style add(1, 's') or add(duration) - function add$1(input, value) { - return addSubtract$1(this, input, value, 1); - } - - // supports only 2.0-style subtract(1, 's') or subtract(duration) - function subtract$1(input, value) { - return addSubtract$1(this, input, value, -1); - } - - function absCeil(number) { - if (number < 0) { - return Math.floor(number); - } else { - return Math.ceil(number); - } - } - - function bubble() { - var milliseconds = this._milliseconds, - days = this._days, - months = this._months, - data = this._data, - seconds, - minutes, - hours, - years, - monthsFromDays; - - // if we have a mix of positive and negative values, bubble down first - // check: https://github.com/moment/moment/issues/2166 - if ( - !( - (milliseconds >= 0 && days >= 0 && months >= 0) || - (milliseconds <= 0 && days <= 0 && months <= 0) - ) - ) { - milliseconds += absCeil(monthsToDays(months) + days) * 864e5; - days = 0; - months = 0; - } - - // The following code bubbles up values, see the tests for - // examples of what that means. - data.milliseconds = milliseconds % 1000; - - seconds = absFloor(milliseconds / 1000); - data.seconds = seconds % 60; - - minutes = absFloor(seconds / 60); - data.minutes = minutes % 60; - - hours = absFloor(minutes / 60); - data.hours = hours % 24; - - days += absFloor(hours / 24); - - // convert days to months - monthsFromDays = absFloor(daysToMonths(days)); - months += monthsFromDays; - days -= absCeil(monthsToDays(monthsFromDays)); - - // 12 months -> 1 year - years = absFloor(months / 12); - months %= 12; - - data.days = days; - data.months = months; - data.years = years; - - return this; - } - - function daysToMonths(days) { - // 400 years have 146097 days (taking into account leap year rules) - // 400 years have 12 months === 4800 - return (days * 4800) / 146097; - } - - function monthsToDays(months) { - // the reverse of daysToMonths - return (months * 146097) / 4800; - } - - function as(units) { - if (!this.isValid()) { - return NaN; - } - var days, - months, - milliseconds = this._milliseconds; - - units = normalizeUnits(units); - - if (units === 'month' || units === 'quarter' || units === 'year') { - days = this._days + milliseconds / 864e5; - months = this._months + daysToMonths(days); - switch (units) { - case 'month': - return months; - case 'quarter': - return months / 3; - case 'year': - return months / 12; - } - } else { - // handle milliseconds separately because of floating point math errors (issue #1867) - days = this._days + Math.round(monthsToDays(this._months)); - switch (units) { - case 'week': - return days / 7 + milliseconds / 6048e5; - case 'day': - return days + milliseconds / 864e5; - case 'hour': - return days * 24 + milliseconds / 36e5; - case 'minute': - return days * 1440 + milliseconds / 6e4; - case 'second': - return days * 86400 + milliseconds / 1000; - // Math.floor prevents floating point math errors here - case 'millisecond': - return Math.floor(days * 864e5) + milliseconds; - default: - throw new Error('Unknown unit ' + units); - } - } - } - - function makeAs(alias) { - return function() { - return this.as(alias); - }; - } - - var asMilliseconds = makeAs('ms'), - asSeconds = makeAs('s'), - asMinutes = makeAs('m'), - asHours = makeAs('h'), - asDays = makeAs('d'), - asWeeks = makeAs('w'), - asMonths = makeAs('M'), - asQuarters = makeAs('Q'), - asYears = makeAs('y'), - valueOf$1 = asMilliseconds; - - function clone$1() { - return createDuration(this); - } - - function get$2(units) { - units = normalizeUnits(units); - return this.isValid() ? this[units + 's']() : NaN; - } - - function makeGetter(name) { - return function() { - return this.isValid() ? this._data[name] : NaN; - }; - } - - var milliseconds = makeGetter('milliseconds'), - seconds = makeGetter('seconds'), - minutes = makeGetter('minutes'), - hours = makeGetter('hours'), - days = makeGetter('days'), - months = makeGetter('months'), - years = makeGetter('years'); - - function weeks() { - return absFloor(this.days() / 7); - } - - var round = Math.round, - thresholds = { - ss: 44, // a few seconds to seconds - s: 45, // seconds to minute - m: 45, // minutes to hour - h: 22, // hours to day - d: 26, // days to month/week - w: null, // weeks to month - M: 11, // months to year - }; - - // helper function for moment.fn.from, moment.fn.fromNow, and moment.duration.fn.humanize - function substituteTimeAgo(string, number, withoutSuffix, isFuture, locale) { - return locale.relativeTime(number || 1, !!withoutSuffix, string, isFuture); - } - - function relativeTime$1(posNegDuration, withoutSuffix, thresholds, locale) { - var duration = createDuration(posNegDuration).abs(), - seconds = round(duration.as('s')), - minutes = round(duration.as('m')), - hours = round(duration.as('h')), - days = round(duration.as('d')), - months = round(duration.as('M')), - weeks = round(duration.as('w')), - years = round(duration.as('y')), - a = - (seconds <= thresholds.ss && ['s', seconds]) || - (seconds < thresholds.s && ['ss', seconds]) || - (minutes <= 1 && ['m']) || - (minutes < thresholds.m && ['mm', minutes]) || - (hours <= 1 && ['h']) || - (hours < thresholds.h && ['hh', hours]) || - (days <= 1 && ['d']) || - (days < thresholds.d && ['dd', days]); - - if (thresholds.w != null) { - a = - a || - (weeks <= 1 && ['w']) || - (weeks < thresholds.w && ['ww', weeks]); - } - a = a || - (months <= 1 && ['M']) || - (months < thresholds.M && ['MM', months]) || - (years <= 1 && ['y']) || ['yy', years]; - - a[2] = withoutSuffix; - a[3] = +posNegDuration > 0; - a[4] = locale; - return substituteTimeAgo.apply(null, a); - } - - // This function allows you to set the rounding function for relative time strings - function getSetRelativeTimeRounding(roundingFunction) { - if (roundingFunction === undefined) { - return round; - } - if (typeof roundingFunction === 'function') { - round = roundingFunction; - return true; - } - return false; - } - - // This function allows you to set a threshold for relative time strings - function getSetRelativeTimeThreshold(threshold, limit) { - if (thresholds[threshold] === undefined) { - return false; - } - if (limit === undefined) { - return thresholds[threshold]; - } - thresholds[threshold] = limit; - if (threshold === 's') { - thresholds.ss = limit - 1; - } - return true; - } - - function humanize(argWithSuffix, argThresholds) { - if (!this.isValid()) { - return this.localeData().invalidDate(); - } - - var withSuffix = false, - th = thresholds, - locale, - output; - - if (typeof argWithSuffix === 'object') { - argThresholds = argWithSuffix; - argWithSuffix = false; - } - if (typeof argWithSuffix === 'boolean') { - withSuffix = argWithSuffix; - } - if (typeof argThresholds === 'object') { - th = Object.assign({}, thresholds, argThresholds); - if (argThresholds.s != null && argThresholds.ss == null) { - th.ss = argThresholds.s - 1; - } - } - - locale = this.localeData(); - output = relativeTime$1(this, !withSuffix, th, locale); - - if (withSuffix) { - output = locale.pastFuture(+this, output); - } - - return locale.postformat(output); - } - - var abs$1 = Math.abs; - - function sign(x) { - return (x > 0) - (x < 0) || +x; - } - - function toISOString$1() { - // for ISO strings we do not use the normal bubbling rules: - // * milliseconds bubble up until they become hours - // * days do not bubble at all - // * months bubble up until they become years - // This is because there is no context-free conversion between hours and days - // (think of clock changes) - // and also not between days and months (28-31 days per month) - if (!this.isValid()) { - return this.localeData().invalidDate(); - } - - var seconds = abs$1(this._milliseconds) / 1000, - days = abs$1(this._days), - months = abs$1(this._months), - minutes, - hours, - years, - s, - total = this.asSeconds(), - totalSign, - ymSign, - daysSign, - hmsSign; - - if (!total) { - // this is the same as C#'s (Noda) and python (isodate)... - // but not other JS (goog.date) - return 'P0D'; - } - - // 3600 seconds -> 60 minutes -> 1 hour - minutes = absFloor(seconds / 60); - hours = absFloor(minutes / 60); - seconds %= 60; - minutes %= 60; - - // 12 months -> 1 year - years = absFloor(months / 12); - months %= 12; - - // inspired by https://github.com/dordille/moment-isoduration/blob/master/moment.isoduration.js - s = seconds ? seconds.toFixed(3).replace(/\.?0+$/, '') : ''; - - totalSign = total < 0 ? '-' : ''; - ymSign = sign(this._months) !== sign(total) ? '-' : ''; - daysSign = sign(this._days) !== sign(total) ? '-' : ''; - hmsSign = sign(this._milliseconds) !== sign(total) ? '-' : ''; - - return ( - totalSign + - 'P' + - (years ? ymSign + years + 'Y' : '') + - (months ? ymSign + months + 'M' : '') + - (days ? daysSign + days + 'D' : '') + - (hours || minutes || seconds ? 'T' : '') + - (hours ? hmsSign + hours + 'H' : '') + - (minutes ? hmsSign + minutes + 'M' : '') + - (seconds ? hmsSign + s + 'S' : '') - ); - } - - var proto$2 = Duration.prototype; - - proto$2.isValid = isValid$1; - proto$2.abs = abs; - proto$2.add = add$1; - proto$2.subtract = subtract$1; - proto$2.as = as; - proto$2.asMilliseconds = asMilliseconds; - proto$2.asSeconds = asSeconds; - proto$2.asMinutes = asMinutes; - proto$2.asHours = asHours; - proto$2.asDays = asDays; - proto$2.asWeeks = asWeeks; - proto$2.asMonths = asMonths; - proto$2.asQuarters = asQuarters; - proto$2.asYears = asYears; - proto$2.valueOf = valueOf$1; - proto$2._bubble = bubble; - proto$2.clone = clone$1; - proto$2.get = get$2; - proto$2.milliseconds = milliseconds; - proto$2.seconds = seconds; - proto$2.minutes = minutes; - proto$2.hours = hours; - proto$2.days = days; - proto$2.weeks = weeks; - proto$2.months = months; - proto$2.years = years; - proto$2.humanize = humanize; - proto$2.toISOString = toISOString$1; - proto$2.toString = toISOString$1; - proto$2.toJSON = toISOString$1; - proto$2.locale = locale; - proto$2.localeData = localeData; - - proto$2.toIsoString = deprecate( - 'toIsoString() is deprecated. Please use toISOString() instead (notice the capitals)', - toISOString$1 - ); - proto$2.lang = lang; - - // FORMATTING - - addFormatToken('X', 0, 0, 'unix'); - addFormatToken('x', 0, 0, 'valueOf'); - - // PARSING - - addRegexToken('x', matchSigned); - addRegexToken('X', matchTimestamp); - addParseToken('X', function(input, array, config) { - config._d = new Date(parseFloat(input) * 1000); - }); - addParseToken('x', function(input, array, config) { - config._d = new Date(toInt(input)); - }); - - //! moment.js - - hooks.version = '2.30.1'; - - setHookCallback(createLocal); - - hooks.fn = proto; - hooks.min = min; - hooks.max = max; - hooks.now = now; - hooks.utc = createUTC; - hooks.unix = createUnix; - hooks.months = listMonths; - hooks.isDate = isDate; - hooks.locale = getSetGlobalLocale; - hooks.invalid = createInvalid; - hooks.duration = createDuration; - hooks.isMoment = isMoment; - hooks.weekdays = listWeekdays; - hooks.parseZone = createInZone; - hooks.localeData = getLocale; - hooks.isDuration = isDuration; - hooks.monthsShort = listMonthsShort; - hooks.weekdaysMin = listWeekdaysMin; - hooks.defineLocale = defineLocale; - hooks.updateLocale = updateLocale; - hooks.locales = listLocales; - hooks.weekdaysShort = listWeekdaysShort; - hooks.normalizeUnits = normalizeUnits; - hooks.relativeTimeRounding = getSetRelativeTimeRounding; - hooks.relativeTimeThreshold = getSetRelativeTimeThreshold; - hooks.calendarFormat = getCalendarFormat; - hooks.prototype = proto; - - // currently HTML5 input type only supports 24-hour formats - hooks.HTML5_FMT = { - DATETIME_LOCAL: 'YYYY-MM-DDTHH:mm', // - DATETIME_LOCAL_SECONDS: 'YYYY-MM-DDTHH:mm:ss', // - DATETIME_LOCAL_MS: 'YYYY-MM-DDTHH:mm:ss.SSS', // - DATE: 'YYYY-MM-DD', // - TIME: 'HH:mm', // - TIME_SECONDS: 'HH:mm:ss', // - TIME_MS: 'HH:mm:ss.SSS', // - WEEK: 'GGGG-[W]WW', // - MONTH: 'YYYY-MM', // - }; - - return hooks; - -}))); diff --git a/static/js/vendor/select2.min.js b/static/js/vendor/select2.min.js deleted file mode 100644 index b073e95d2d..0000000000 --- a/static/js/vendor/select2.min.js +++ /dev/null @@ -1,2 +0,0 @@ -/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ -!function(n){"function"==typeof define&&define.amd?define(["jquery"],n):"object"==typeof module&&module.exports?module.exports=function(e,t){return void 0===t&&(t="undefined"!=typeof window?require("jquery"):require("jquery")(e)),n(t),t}:n(jQuery)}(function(u){var e=function(){if(u&&u.fn&&u.fn.select2&&u.fn.select2.amd)var e=u.fn.select2.amd;var t,n,r,h,o,s,f,g,m,v,y,_,i,a,b;function w(e,t){return i.call(e,t)}function l(e,t){var n,r,i,o,s,a,l,c,u,d,p,h=t&&t.split("/"),f=y.map,g=f&&f["*"]||{};if(e){for(s=(e=e.split("/")).length-1,y.nodeIdCompat&&b.test(e[s])&&(e[s]=e[s].replace(b,"")),"."===e[0].charAt(0)&&h&&(e=h.slice(0,h.length-1).concat(e)),u=0;u":">",'"':""","'":"'","/":"/"};return"string"!=typeof e?e:String(e).replace(/[&<>"'\/\\]/g,function(e){return t[e]})},i.appendMany=function(e,t){if("1.7"===o.fn.jquery.substr(0,3)){var n=o();o.map(t,function(e){n=n.add(e)}),t=n}e.append(t)},i.__cache={};var n=0;return i.GetUniqueElementId=function(e){var t=e.getAttribute("data-select2-id");return null==t&&(e.id?(t=e.id,e.setAttribute("data-select2-id",t)):(e.setAttribute("data-select2-id",++n),t=n.toString())),t},i.StoreData=function(e,t,n){var r=i.GetUniqueElementId(e);i.__cache[r]||(i.__cache[r]={}),i.__cache[r][t]=n},i.GetData=function(e,t){var n=i.GetUniqueElementId(e);return t?i.__cache[n]&&null!=i.__cache[n][t]?i.__cache[n][t]:o(e).data(t):i.__cache[n]},i.RemoveData=function(e){var t=i.GetUniqueElementId(e);null!=i.__cache[t]&&delete i.__cache[t],e.removeAttribute("data-select2-id")},i}),e.define("select2/results",["jquery","./utils"],function(h,f){function r(e,t,n){this.$element=e,this.data=n,this.options=t,r.__super__.constructor.call(this)}return f.Extend(r,f.Observable),r.prototype.render=function(){var e=h('
      ');return this.options.get("multiple")&&e.attr("aria-multiselectable","true"),this.$results=e},r.prototype.clear=function(){this.$results.empty()},r.prototype.displayMessage=function(e){var t=this.options.get("escapeMarkup");this.clear(),this.hideLoading();var n=h(''),r=this.options.get("translations").get(e.message);n.append(t(r(e.args))),n[0].className+=" select2-results__message",this.$results.append(n)},r.prototype.hideMessages=function(){this.$results.find(".select2-results__message").remove()},r.prototype.append=function(e){this.hideLoading();var t=[];if(null!=e.results&&0!==e.results.length){e.results=this.sort(e.results);for(var n=0;n",{class:"select2-results__options select2-results__options--nested"});p.append(l),s.append(a),s.append(p)}else this.template(e,t);return f.StoreData(t,"data",e),t},r.prototype.bind=function(t,e){var l=this,n=t.id+"-results";this.$results.attr("id",n),t.on("results:all",function(e){l.clear(),l.append(e.data),t.isOpen()&&(l.setClasses(),l.highlightFirstItem())}),t.on("results:append",function(e){l.append(e.data),t.isOpen()&&l.setClasses()}),t.on("query",function(e){l.hideMessages(),l.showLoading(e)}),t.on("select",function(){t.isOpen()&&(l.setClasses(),l.options.get("scrollAfterSelect")&&l.highlightFirstItem())}),t.on("unselect",function(){t.isOpen()&&(l.setClasses(),l.options.get("scrollAfterSelect")&&l.highlightFirstItem())}),t.on("open",function(){l.$results.attr("aria-expanded","true"),l.$results.attr("aria-hidden","false"),l.setClasses(),l.ensureHighlightVisible()}),t.on("close",function(){l.$results.attr("aria-expanded","false"),l.$results.attr("aria-hidden","true"),l.$results.removeAttr("aria-activedescendant")}),t.on("results:toggle",function(){var e=l.getHighlightedResults();0!==e.length&&e.trigger("mouseup")}),t.on("results:select",function(){var e=l.getHighlightedResults();if(0!==e.length){var t=f.GetData(e[0],"data");"true"==e.attr("aria-selected")?l.trigger("close",{}):l.trigger("select",{data:t})}}),t.on("results:previous",function(){var e=l.getHighlightedResults(),t=l.$results.find("[aria-selected]"),n=t.index(e);if(!(n<=0)){var r=n-1;0===e.length&&(r=0);var i=t.eq(r);i.trigger("mouseenter");var o=l.$results.offset().top,s=i.offset().top,a=l.$results.scrollTop()+(s-o);0===r?l.$results.scrollTop(0):s-o<0&&l.$results.scrollTop(a)}}),t.on("results:next",function(){var e=l.getHighlightedResults(),t=l.$results.find("[aria-selected]"),n=t.index(e)+1;if(!(n>=t.length)){var r=t.eq(n);r.trigger("mouseenter");var i=l.$results.offset().top+l.$results.outerHeight(!1),o=r.offset().top+r.outerHeight(!1),s=l.$results.scrollTop()+o-i;0===n?l.$results.scrollTop(0):ithis.$results.outerHeight()||o<0)&&this.$results.scrollTop(i)}},r.prototype.template=function(e,t){var n=this.options.get("templateResult"),r=this.options.get("escapeMarkup"),i=n(e,t);null==i?t.style.display="none":"string"==typeof i?t.innerHTML=r(i):h(t).append(i)},r}),e.define("select2/keys",[],function(){return{BACKSPACE:8,TAB:9,ENTER:13,SHIFT:16,CTRL:17,ALT:18,ESC:27,SPACE:32,PAGE_UP:33,PAGE_DOWN:34,END:35,HOME:36,LEFT:37,UP:38,RIGHT:39,DOWN:40,DELETE:46}}),e.define("select2/selection/base",["jquery","../utils","../keys"],function(n,r,i){function o(e,t){this.$element=e,this.options=t,o.__super__.constructor.call(this)}return r.Extend(o,r.Observable),o.prototype.render=function(){var e=n('');return this._tabindex=0,null!=r.GetData(this.$element[0],"old-tabindex")?this._tabindex=r.GetData(this.$element[0],"old-tabindex"):null!=this.$element.attr("tabindex")&&(this._tabindex=this.$element.attr("tabindex")),e.attr("title",this.$element.attr("title")),e.attr("tabindex",this._tabindex),e.attr("aria-disabled","false"),this.$selection=e},o.prototype.bind=function(e,t){var n=this,r=e.id+"-results";this.container=e,this.$selection.on("focus",function(e){n.trigger("focus",e)}),this.$selection.on("blur",function(e){n._handleBlur(e)}),this.$selection.on("keydown",function(e){n.trigger("keypress",e),e.which===i.SPACE&&e.preventDefault()}),e.on("results:focus",function(e){n.$selection.attr("aria-activedescendant",e.data._resultId)}),e.on("selection:update",function(e){n.update(e.data)}),e.on("open",function(){n.$selection.attr("aria-expanded","true"),n.$selection.attr("aria-owns",r),n._attachCloseHandler(e)}),e.on("close",function(){n.$selection.attr("aria-expanded","false"),n.$selection.removeAttr("aria-activedescendant"),n.$selection.removeAttr("aria-owns"),n.$selection.trigger("focus"),n._detachCloseHandler(e)}),e.on("enable",function(){n.$selection.attr("tabindex",n._tabindex),n.$selection.attr("aria-disabled","false")}),e.on("disable",function(){n.$selection.attr("tabindex","-1"),n.$selection.attr("aria-disabled","true")})},o.prototype._handleBlur=function(e){var t=this;window.setTimeout(function(){document.activeElement==t.$selection[0]||n.contains(t.$selection[0],document.activeElement)||t.trigger("blur",e)},1)},o.prototype._attachCloseHandler=function(e){n(document.body).on("mousedown.select2."+e.id,function(e){var t=n(e.target).closest(".select2");n(".select2.select2-container--open").each(function(){this!=t[0]&&r.GetData(this,"element").select2("close")})})},o.prototype._detachCloseHandler=function(e){n(document.body).off("mousedown.select2."+e.id)},o.prototype.position=function(e,t){t.find(".selection").append(e)},o.prototype.destroy=function(){this._detachCloseHandler(this.container)},o.prototype.update=function(e){throw new Error("The `update` method must be defined in child classes.")},o.prototype.isEnabled=function(){return!this.isDisabled()},o.prototype.isDisabled=function(){return this.options.get("disabled")},o}),e.define("select2/selection/single",["jquery","./base","../utils","../keys"],function(e,t,n,r){function i(){i.__super__.constructor.apply(this,arguments)}return n.Extend(i,t),i.prototype.render=function(){var e=i.__super__.render.call(this);return e.addClass("select2-selection--single"),e.html(''),e},i.prototype.bind=function(t,e){var n=this;i.__super__.bind.apply(this,arguments);var r=t.id+"-container";this.$selection.find(".select2-selection__rendered").attr("id",r).attr("role","textbox").attr("aria-readonly","true"),this.$selection.attr("aria-labelledby",r),this.$selection.on("mousedown",function(e){1===e.which&&n.trigger("toggle",{originalEvent:e})}),this.$selection.on("focus",function(e){}),this.$selection.on("blur",function(e){}),t.on("focus",function(e){t.isOpen()||n.$selection.trigger("focus")})},i.prototype.clear=function(){var e=this.$selection.find(".select2-selection__rendered");e.empty(),e.removeAttr("title")},i.prototype.display=function(e,t){var n=this.options.get("templateSelection");return this.options.get("escapeMarkup")(n(e,t))},i.prototype.selectionContainer=function(){return e("")},i.prototype.update=function(e){if(0!==e.length){var t=e[0],n=this.$selection.find(".select2-selection__rendered"),r=this.display(t,n);n.empty().append(r);var i=t.title||t.text;i?n.attr("title",i):n.removeAttr("title")}else this.clear()},i}),e.define("select2/selection/multiple",["jquery","./base","../utils"],function(i,e,l){function n(e,t){n.__super__.constructor.apply(this,arguments)}return l.Extend(n,e),n.prototype.render=function(){var e=n.__super__.render.call(this);return e.addClass("select2-selection--multiple"),e.html('
        '),e},n.prototype.bind=function(e,t){var r=this;n.__super__.bind.apply(this,arguments),this.$selection.on("click",function(e){r.trigger("toggle",{originalEvent:e})}),this.$selection.on("click",".select2-selection__choice__remove",function(e){if(!r.isDisabled()){var t=i(this).parent(),n=l.GetData(t[0],"data");r.trigger("unselect",{originalEvent:e,data:n})}})},n.prototype.clear=function(){var e=this.$selection.find(".select2-selection__rendered");e.empty(),e.removeAttr("title")},n.prototype.display=function(e,t){var n=this.options.get("templateSelection");return this.options.get("escapeMarkup")(n(e,t))},n.prototype.selectionContainer=function(){return i('
      • ×
      • ')},n.prototype.update=function(e){if(this.clear(),0!==e.length){for(var t=[],n=0;n×
        ');a.StoreData(r[0],"data",t),this.$selection.find(".select2-selection__rendered").prepend(r)}},e}),e.define("select2/selection/search",["jquery","../utils","../keys"],function(r,a,l){function e(e,t,n){e.call(this,t,n)}return e.prototype.render=function(e){var t=r('');this.$searchContainer=t,this.$search=t.find("input");var n=e.call(this);return this._transferTabIndex(),n},e.prototype.bind=function(e,t,n){var r=this,i=t.id+"-results";e.call(this,t,n),t.on("open",function(){r.$search.attr("aria-controls",i),r.$search.trigger("focus")}),t.on("close",function(){r.$search.val(""),r.$search.removeAttr("aria-controls"),r.$search.removeAttr("aria-activedescendant"),r.$search.trigger("focus")}),t.on("enable",function(){r.$search.prop("disabled",!1),r._transferTabIndex()}),t.on("disable",function(){r.$search.prop("disabled",!0)}),t.on("focus",function(e){r.$search.trigger("focus")}),t.on("results:focus",function(e){e.data._resultId?r.$search.attr("aria-activedescendant",e.data._resultId):r.$search.removeAttr("aria-activedescendant")}),this.$selection.on("focusin",".select2-search--inline",function(e){r.trigger("focus",e)}),this.$selection.on("focusout",".select2-search--inline",function(e){r._handleBlur(e)}),this.$selection.on("keydown",".select2-search--inline",function(e){if(e.stopPropagation(),r.trigger("keypress",e),r._keyUpPrevented=e.isDefaultPrevented(),e.which===l.BACKSPACE&&""===r.$search.val()){var t=r.$searchContainer.prev(".select2-selection__choice");if(0this.maximumInputLength?this.trigger("results:message",{message:"inputTooLong",args:{maximum:this.maximumInputLength,input:t.term,params:t}}):e.call(this,t,n)},e}),e.define("select2/data/maximumSelectionLength",[],function(){function e(e,t,n){this.maximumSelectionLength=n.get("maximumSelectionLength"),e.call(this,t,n)}return e.prototype.bind=function(e,t,n){var r=this;e.call(this,t,n),t.on("select",function(){r._checkIfMaximumSelected()})},e.prototype.query=function(e,t,n){var r=this;this._checkIfMaximumSelected(function(){e.call(r,t,n)})},e.prototype._checkIfMaximumSelected=function(e,n){var r=this;this.current(function(e){var t=null!=e?e.length:0;0=r.maximumSelectionLength?r.trigger("results:message",{message:"maximumSelected",args:{maximum:r.maximumSelectionLength}}):n&&n()})},e}),e.define("select2/dropdown",["jquery","./utils"],function(t,e){function n(e,t){this.$element=e,this.options=t,n.__super__.constructor.call(this)}return e.Extend(n,e.Observable),n.prototype.render=function(){var e=t('');return e.attr("dir",this.options.get("dir")),this.$dropdown=e},n.prototype.bind=function(){},n.prototype.position=function(e,t){},n.prototype.destroy=function(){this.$dropdown.remove()},n}),e.define("select2/dropdown/search",["jquery","../utils"],function(o,e){function t(){}return t.prototype.render=function(e){var t=e.call(this),n=o('');return this.$searchContainer=n,this.$search=n.find("input"),t.prepend(n),t},t.prototype.bind=function(e,t,n){var r=this,i=t.id+"-results";e.call(this,t,n),this.$search.on("keydown",function(e){r.trigger("keypress",e),r._keyUpPrevented=e.isDefaultPrevented()}),this.$search.on("input",function(e){o(this).off("keyup")}),this.$search.on("keyup input",function(e){r.handleSearch(e)}),t.on("open",function(){r.$search.attr("tabindex",0),r.$search.attr("aria-controls",i),r.$search.trigger("focus"),window.setTimeout(function(){r.$search.trigger("focus")},0)}),t.on("close",function(){r.$search.attr("tabindex",-1),r.$search.removeAttr("aria-controls"),r.$search.removeAttr("aria-activedescendant"),r.$search.val(""),r.$search.trigger("blur")}),t.on("focus",function(){t.isOpen()||r.$search.trigger("focus")}),t.on("results:all",function(e){null!=e.query.term&&""!==e.query.term||(r.showSearch(e)?r.$searchContainer.removeClass("select2-search--hide"):r.$searchContainer.addClass("select2-search--hide"))}),t.on("results:focus",function(e){e.data._resultId?r.$search.attr("aria-activedescendant",e.data._resultId):r.$search.removeAttr("aria-activedescendant")})},t.prototype.handleSearch=function(e){if(!this._keyUpPrevented){var t=this.$search.val();this.trigger("query",{term:t})}this._keyUpPrevented=!1},t.prototype.showSearch=function(e,t){return!0},t}),e.define("select2/dropdown/hidePlaceholder",[],function(){function e(e,t,n,r){this.placeholder=this.normalizePlaceholder(n.get("placeholder")),e.call(this,t,n,r)}return e.prototype.append=function(e,t){t.results=this.removePlaceholder(t.results),e.call(this,t)},e.prototype.normalizePlaceholder=function(e,t){return"string"==typeof t&&(t={id:"",text:t}),t},e.prototype.removePlaceholder=function(e,t){for(var n=t.slice(0),r=t.length-1;0<=r;r--){var i=t[r];this.placeholder.id===i.id&&n.splice(r,1)}return n},e}),e.define("select2/dropdown/infiniteScroll",["jquery"],function(n){function e(e,t,n,r){this.lastParams={},e.call(this,t,n,r),this.$loadingMore=this.createLoadingMore(),this.loading=!1}return e.prototype.append=function(e,t){this.$loadingMore.remove(),this.loading=!1,e.call(this,t),this.showLoadingMore(t)&&(this.$results.append(this.$loadingMore),this.loadMoreIfNeeded())},e.prototype.bind=function(e,t,n){var r=this;e.call(this,t,n),t.on("query",function(e){r.lastParams=e,r.loading=!0}),t.on("query:append",function(e){r.lastParams=e,r.loading=!0}),this.$results.on("scroll",this.loadMoreIfNeeded.bind(this))},e.prototype.loadMoreIfNeeded=function(){var e=n.contains(document.documentElement,this.$loadingMore[0]);if(!this.loading&&e){var t=this.$results.offset().top+this.$results.outerHeight(!1);this.$loadingMore.offset().top+this.$loadingMore.outerHeight(!1)<=t+50&&this.loadMore()}},e.prototype.loadMore=function(){this.loading=!0;var e=n.extend({},{page:1},this.lastParams);e.page++,this.trigger("query:append",e)},e.prototype.showLoadingMore=function(e,t){return t.pagination&&t.pagination.more},e.prototype.createLoadingMore=function(){var e=n('
      • '),t=this.options.get("translations").get("loadingMore");return e.html(t(this.lastParams)),e},e}),e.define("select2/dropdown/attachBody",["jquery","../utils"],function(f,a){function e(e,t,n){this.$dropdownParent=f(n.get("dropdownParent")||document.body),e.call(this,t,n)}return e.prototype.bind=function(e,t,n){var r=this;e.call(this,t,n),t.on("open",function(){r._showDropdown(),r._attachPositioningHandler(t),r._bindContainerResultHandlers(t)}),t.on("close",function(){r._hideDropdown(),r._detachPositioningHandler(t)}),this.$dropdownContainer.on("mousedown",function(e){e.stopPropagation()})},e.prototype.destroy=function(e){e.call(this),this.$dropdownContainer.remove()},e.prototype.position=function(e,t,n){t.attr("class",n.attr("class")),t.removeClass("select2"),t.addClass("select2-container--open"),t.css({position:"absolute",top:-999999}),this.$container=n},e.prototype.render=function(e){var t=f(""),n=e.call(this);return t.append(n),this.$dropdownContainer=t},e.prototype._hideDropdown=function(e){this.$dropdownContainer.detach()},e.prototype._bindContainerResultHandlers=function(e,t){if(!this._containerResultsHandlersBound){var n=this;t.on("results:all",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("results:append",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("results:message",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("select",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("unselect",function(){n._positionDropdown(),n._resizeDropdown()}),this._containerResultsHandlersBound=!0}},e.prototype._attachPositioningHandler=function(e,t){var n=this,r="scroll.select2."+t.id,i="resize.select2."+t.id,o="orientationchange.select2."+t.id,s=this.$container.parents().filter(a.hasScroll);s.each(function(){a.StoreData(this,"select2-scroll-position",{x:f(this).scrollLeft(),y:f(this).scrollTop()})}),s.on(r,function(e){var t=a.GetData(this,"select2-scroll-position");f(this).scrollTop(t.y)}),f(window).on(r+" "+i+" "+o,function(e){n._positionDropdown(),n._resizeDropdown()})},e.prototype._detachPositioningHandler=function(e,t){var n="scroll.select2."+t.id,r="resize.select2."+t.id,i="orientationchange.select2."+t.id;this.$container.parents().filter(a.hasScroll).off(n),f(window).off(n+" "+r+" "+i)},e.prototype._positionDropdown=function(){var e=f(window),t=this.$dropdown.hasClass("select2-dropdown--above"),n=this.$dropdown.hasClass("select2-dropdown--below"),r=null,i=this.$container.offset();i.bottom=i.top+this.$container.outerHeight(!1);var o={height:this.$container.outerHeight(!1)};o.top=i.top,o.bottom=i.top+o.height;var s=this.$dropdown.outerHeight(!1),a=e.scrollTop(),l=e.scrollTop()+e.height(),c=ai.bottom+s,d={left:i.left,top:o.bottom},p=this.$dropdownParent;"static"===p.css("position")&&(p=p.offsetParent());var h={top:0,left:0};(f.contains(document.body,p[0])||p[0].isConnected)&&(h=p.offset()),d.top-=h.top,d.left-=h.left,t||n||(r="below"),u||!c||t?!c&&u&&t&&(r="below"):r="above",("above"==r||t&&"below"!==r)&&(d.top=o.top-h.top-s),null!=r&&(this.$dropdown.removeClass("select2-dropdown--below select2-dropdown--above").addClass("select2-dropdown--"+r),this.$container.removeClass("select2-container--below select2-container--above").addClass("select2-container--"+r)),this.$dropdownContainer.css(d)},e.prototype._resizeDropdown=function(){var e={width:this.$container.outerWidth(!1)+"px"};this.options.get("dropdownAutoWidth")&&(e.minWidth=e.width,e.position="relative",e.width="auto"),this.$dropdown.css(e)},e.prototype._showDropdown=function(e){this.$dropdownContainer.appendTo(this.$dropdownParent),this._positionDropdown(),this._resizeDropdown()},e}),e.define("select2/dropdown/minimumResultsForSearch",[],function(){function e(e,t,n,r){this.minimumResultsForSearch=n.get("minimumResultsForSearch"),this.minimumResultsForSearch<0&&(this.minimumResultsForSearch=1/0),e.call(this,t,n,r)}return e.prototype.showSearch=function(e,t){return!(function e(t){for(var n=0,r=0;r
        ');return e.attr("dir",this.options.get("dir")),this.$container=e,this.$container.addClass("select2-container--"+this.options.get("theme")),u.StoreData(e[0],"element",this.$element),e},d}),e.define("jquery-mousewheel",["jquery"],function(e){return e}),e.define("jquery.select2",["jquery","jquery-mousewheel","./select2/core","./select2/defaults","./select2/utils"],function(i,e,o,t,s){if(null==i.fn.select2){var a=["open","close","destroy"];i.fn.select2=function(t){if("object"==typeof(t=t||{}))return this.each(function(){var e=i.extend(!0,{},t);new o(i(this),e)}),this;if("string"!=typeof t)throw new Error("Invalid arguments for Select2: "+t);var n,r=Array.prototype.slice.call(arguments,1);return this.each(function(){var e=s.GetData(this,"select2");null==e&&window.console&&console.error&&console.error("The select2('"+t+"') method was called on an element that is not using Select2."),n=e[t].apply(e,r)}),-1 "[object Object]" === Object.prototype.toString.call(t), - e = e => { - let s = !1; - try { - s = JSON.parse(e) - } catch (t) { - return !1 - } - return !(null === s || !Array.isArray(s) && !t(s)) && s - }, - s = (t, e) => { - const s = document.createElement(t); - if (e && "object" == typeof e) - for (const t in e) "html" === t ? s.innerHTML = e[t] : s.setAttribute(t, e[t]); - return s - }, - i = t => ["#text", "#comment"].includes(t.nodeName) ? t.data : t.childNodes ? t.childNodes.map((t => i(t))).join("") : "", - n = t => { - if (null == t) return ""; - if (t.hasOwnProperty("text") || t.hasOwnProperty("data")) { - const e = t; - return e.text ?? n(e.data) - } - return t.hasOwnProperty("nodeName") ? i(t) : String(t) - }, - a = function(t) { - return t.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """) - }, - o = function(t, e) { - let s = 0, - i = 0; - for (; s < t + 1;) { - e[i].hidden || (s += 1), i += 1 - } - return i - 1 - }, - r = function(t) { - const e = {}; - if (t) - for (const s of t) e[s.name] = s.value; - return e - }, - l = t => t ? t.trim().split(" ").map((t => `.${t}`)).join("") : null, - d = (t, e) => { - const s = e?.split(" ").some((e => !t.classList.contains(e))); - return !s - }, - c = (t, e) => t ? e ? `${t} ${e}` : t : e || "", - h = function(t, e = 300) { - let s; - return (...i) => { - clearTimeout(s), s = window.setTimeout((() => t()), e) - } - }; - var u = function() { - return u = Object.assign || function(t) { - for (var e, s = arguments, i = 1, n = arguments.length; i < n; i++) - for (var a in e = s[i]) Object.prototype.hasOwnProperty.call(e, a) && (t[a] = e[a]); - return t - }, u.apply(this, arguments) - }; - - function p(t, e, s) { - if (s || 2 === arguments.length) - for (var i, n = 0, a = e.length; n < a; n++) !i && n in e || (i || (i = Array.prototype.slice.call(e, 0, n)), i[n] = e[n]); - return t.concat(i || Array.prototype.slice.call(e)) - } - "function" == typeof SuppressedError && SuppressedError; - var f = function() { - function t(t) { - void 0 === t && (t = {}); - var e = this; - Object.entries(t).forEach((function(t) { - var s = t[0], - i = t[1]; - return e[s] = i - })) - } - return t.prototype.toString = function() { - return JSON.stringify(this) - }, t.prototype.setValue = function(t, e) { - return this[t] = e, this - }, t - }(); - - function m(t) { - for (var e = arguments, s = [], i = 1; i < arguments.length; i++) s[i - 1] = e[i]; - return null != t && s.some((function(e) { - var s, i; - return "function" == typeof(null === (i = null === (s = null == t ? void 0 : t.ownerDocument) || void 0 === s ? void 0 : s.defaultView) || void 0 === i ? void 0 : i[e]) && t instanceof t.ownerDocument.defaultView[e] - })) - } - - function g(t, e, s) { - var i; - return "#text" === t.nodeName ? i = s.document.createTextNode(t.data) : "#comment" === t.nodeName ? i = s.document.createComment(t.data) : (e ? (i = s.document.createElementNS("http://www.w3.org/2000/svg", t.nodeName), "foreignObject" === t.nodeName && (e = !1)) : "svg" === t.nodeName.toLowerCase() ? (i = s.document.createElementNS("http://www.w3.org/2000/svg", "svg"), e = !0) : i = s.document.createElement(t.nodeName), t.attributes && Object.entries(t.attributes).forEach((function(t) { - var e = t[0], - s = t[1]; - return i.setAttribute(e, s) - })), t.childNodes && t.childNodes.forEach((function(t) { - return i.appendChild(g(t, e, s)) - })), s.valueDiffing && (t.value && m(i, "HTMLButtonElement", "HTMLDataElement", "HTMLInputElement", "HTMLLIElement", "HTMLMeterElement", "HTMLOptionElement", "HTMLProgressElement", "HTMLParamElement") && (i.value = t.value), t.checked && m(i, "HTMLInputElement") && (i.checked = t.checked), t.selected && m(i, "HTMLOptionElement") && (i.selected = t.selected))), i - } - var b = function(t, e) { - for (e = e.slice(); e.length > 0;) { - var s = e.splice(0, 1)[0]; - t = t.childNodes[s] - } - return t - }; - - function v(t, e, s) { - var i, n, a, o = e[s._const.action], - r = e[s._const.route]; - [s._const.addElement, s._const.addTextElement].includes(o) || (i = b(t, r)); - var l = { - diff: e, - node: i - }; - if (s.preDiffApply(l)) return !0; - switch (o) { - case s._const.addAttribute: - if (!i || !m(i, "Element")) return !1; - i.setAttribute(e[s._const.name], e[s._const.value]); - break; - case s._const.modifyAttribute: - if (!i || !m(i, "Element")) return !1; - i.setAttribute(e[s._const.name], e[s._const.newValue]), m(i, "HTMLInputElement") && "value" === e[s._const.name] && (i.value = e[s._const.newValue]); - break; - case s._const.removeAttribute: - if (!i || !m(i, "Element")) return !1; - i.removeAttribute(e[s._const.name]); - break; - case s._const.modifyTextElement: - if (!i || !m(i, "Text")) return !1; - s.textDiff(i, i.data, e[s._const.oldValue], e[s._const.newValue]), m(i.parentNode, "HTMLTextAreaElement") && (i.parentNode.value = e[s._const.newValue]); - break; - case s._const.modifyValue: - if (!i || void 0 === i.value) return !1; - i.value = e[s._const.newValue]; - break; - case s._const.modifyComment: - if (!i || !m(i, "Comment")) return !1; - s.textDiff(i, i.data, e[s._const.oldValue], e[s._const.newValue]); - break; - case s._const.modifyChecked: - if (!i || void 0 === i.checked) return !1; - i.checked = e[s._const.newValue]; - break; - case s._const.modifySelected: - if (!i || void 0 === i.selected) return !1; - i.selected = e[s._const.newValue]; - break; - case s._const.replaceElement: - var d = "svg" === e[s._const.newValue].nodeName.toLowerCase() || "http://www.w3.org/2000/svg" === i.parentNode.namespaceURI; - i.parentNode.replaceChild(g(e[s._const.newValue], d, s), i); - break; - case s._const.relocateGroup: - p([], new Array(e[s._const.groupLength]), !0).map((function() { - return i.removeChild(i.childNodes[e[s._const.from]]) - })).forEach((function(t, n) { - 0 === n && (a = i.childNodes[e[s._const.to]]), i.insertBefore(t, a || null) - })); - break; - case s._const.removeElement: - i.parentNode.removeChild(i); - break; - case s._const.addElement: - var c = (u = r.slice()).splice(u.length - 1, 1)[0]; - if (!m(i = b(t, u), "Element")) return !1; - i.insertBefore(g(e[s._const.element], "http://www.w3.org/2000/svg" === i.namespaceURI, s), i.childNodes[c] || null); - break; - case s._const.removeTextElement: - if (!i || 3 !== i.nodeType) return !1; - var h = i.parentNode; - h.removeChild(i), m(h, "HTMLTextAreaElement") && (h.value = ""); - break; - case s._const.addTextElement: - var u; - c = (u = r.slice()).splice(u.length - 1, 1)[0]; - if (n = s.document.createTextNode(e[s._const.value]), !(i = b(t, u)).childNodes) return !1; - i.insertBefore(n, i.childNodes[c] || null), m(i.parentNode, "HTMLTextAreaElement") && (i.parentNode.value = e[s._const.value]); - break; - default: - console.log("unknown action") - } - return s.postDiffApply({ - diff: l.diff, - node: l.node, - newNode: n - }), !0 - } - - function _(t, e, s) { - var i = t[e]; - t[e] = t[s], t[s] = i - } - - function w(t, e, s) { - (e = e.slice()).reverse(), e.forEach((function(e) { - ! function(t, e, s) { - switch (e[s._const.action]) { - case s._const.addAttribute: - e[s._const.action] = s._const.removeAttribute, v(t, e, s); - break; - case s._const.modifyAttribute: - _(e, s._const.oldValue, s._const.newValue), v(t, e, s); - break; - case s._const.removeAttribute: - e[s._const.action] = s._const.addAttribute, v(t, e, s); - break; - case s._const.modifyTextElement: - case s._const.modifyValue: - case s._const.modifyComment: - case s._const.modifyChecked: - case s._const.modifySelected: - case s._const.replaceElement: - _(e, s._const.oldValue, s._const.newValue), v(t, e, s); - break; - case s._const.relocateGroup: - _(e, s._const.from, s._const.to), v(t, e, s); - break; - case s._const.removeElement: - e[s._const.action] = s._const.addElement, v(t, e, s); - break; - case s._const.addElement: - e[s._const.action] = s._const.removeElement, v(t, e, s); - break; - case s._const.removeTextElement: - e[s._const.action] = s._const.addTextElement, v(t, e, s); - break; - case s._const.addTextElement: - e[s._const.action] = s._const.removeTextElement, v(t, e, s); - break; - default: - console.log("unknown action") - } - }(t, e, s) - })) - } - var y = function(t) { - var e = []; - return e.push(t.nodeName), "#text" !== t.nodeName && "#comment" !== t.nodeName && t.attributes && (t.attributes.class && e.push("".concat(t.nodeName, ".").concat(t.attributes.class.replace(/ /g, "."))), t.attributes.id && e.push("".concat(t.nodeName, "#").concat(t.attributes.id))), e - }, - M = function(t) { - var e = {}, - s = {}; - return t.forEach((function(t) { - y(t).forEach((function(t) { - var i = t in e; - i || t in s ? i && (delete e[t], s[t] = !0) : e[t] = !0 - })) - })), e - }, - D = function(t, e) { - var s = M(t), - i = M(e), - n = {}; - return Object.keys(s).forEach((function(t) { - i[t] && (n[t] = !0) - })), n - }, - N = function(t) { - return delete t.outerDone, delete t.innerDone, delete t.valueDone, !t.childNodes || t.childNodes.every(N) - }, - x = function(t) { - if (Object.prototype.hasOwnProperty.call(t, "data")) return { - nodeName: "#text" === t.nodeName ? "#text" : "#comment", - data: t.data - }; - var e = { - nodeName: t.nodeName - }; - return Object.prototype.hasOwnProperty.call(t, "attributes") && (e.attributes = u({}, t.attributes)), Object.prototype.hasOwnProperty.call(t, "checked") && (e.checked = t.checked), Object.prototype.hasOwnProperty.call(t, "value") && (e.value = t.value), Object.prototype.hasOwnProperty.call(t, "selected") && (e.selected = t.selected), Object.prototype.hasOwnProperty.call(t, "childNodes") && (e.childNodes = t.childNodes.map((function(t) { - return x(t) - }))), e - }, - O = function(t, e) { - if (!["nodeName", "value", "checked", "selected", "data"].every((function(s) { - return t[s] === e[s] - }))) return !1; - if (Object.prototype.hasOwnProperty.call(t, "data")) return !0; - if (Boolean(t.attributes) !== Boolean(e.attributes)) return !1; - if (Boolean(t.childNodes) !== Boolean(e.childNodes)) return !1; - if (t.attributes) { - var s = Object.keys(t.attributes), - i = Object.keys(e.attributes); - if (s.length !== i.length) return !1; - if (!s.every((function(s) { - return t.attributes[s] === e.attributes[s] - }))) return !1 - } - if (t.childNodes) { - if (t.childNodes.length !== e.childNodes.length) return !1; - if (!t.childNodes.every((function(t, s) { - return O(t, e.childNodes[s]) - }))) return !1 - } - return !0 - }, - E = function(t, e, s, i, n) { - if (void 0 === n && (n = !1), !t || !e) return !1; - if (t.nodeName !== e.nodeName) return !1; - if (["#text", "#comment"].includes(t.nodeName)) return !!n || t.data === e.data; - if (t.nodeName in s) return !0; - if (t.attributes && e.attributes) { - if (t.attributes.id) { - if (t.attributes.id !== e.attributes.id) return !1; - if ("".concat(t.nodeName, "#").concat(t.attributes.id) in s) return !0 - } - if (t.attributes.class && t.attributes.class === e.attributes.class) - if ("".concat(t.nodeName, ".").concat(t.attributes.class.replace(/ /g, ".")) in s) return !0 - } - if (i) return !0; - var a = t.childNodes ? t.childNodes.slice().reverse() : [], - o = e.childNodes ? e.childNodes.slice().reverse() : []; - if (a.length !== o.length) return !1; - if (n) return a.every((function(t, e) { - return t.nodeName === o[e].nodeName - })); - var r = D(a, o); - return a.every((function(t, e) { - return E(t, o[e], r, !0, !0) - })) - }, - V = function(t, e) { - return p([], new Array(t), !0).map((function() { - return e - })) - }, - $ = function(t, e) { - for (var s = t.childNodes ? t.childNodes : [], i = e.childNodes ? e.childNodes : [], n = V(s.length, !1), a = V(i.length, !1), o = [], r = function() { - return arguments[1] - }, l = !1, d = function() { - var t = function(t, e, s, i) { - var n = 0, - a = [], - o = t.length, - r = e.length, - l = p([], new Array(o + 1), !0).map((function() { - return [] - })), - d = D(t, e), - c = o === r; - c && t.some((function(t, s) { - var i = y(t), - n = y(e[s]); - return i.length !== n.length ? (c = !1, !0) : (i.some((function(t, e) { - if (t !== n[e]) return c = !1, !0 - })), !c || void 0) - })); - for (var h = 0; h < o; h++) - for (var u = t[h], f = 0; f < r; f++) { - var m = e[f]; - s[h] || i[f] || !E(u, m, d, c) ? l[h + 1][f + 1] = 0 : (l[h + 1][f + 1] = l[h][f] ? l[h][f] + 1 : 1, l[h + 1][f + 1] >= n && (n = l[h + 1][f + 1], a = [h + 1, f + 1])) - } - return 0 !== n && { - oldValue: a[0] - n, - newValue: a[1] - n, - length: n - } - }(s, i, n, a); - t ? (o.push(t), p([], new Array(t.length), !0).map(r).forEach((function(e) { - return function(t, e, s, i) { - t[s.oldValue + i] = !0, e[s.newValue + i] = !0 - }(n, a, t, e) - }))) : l = !0 - }; !l;) d(); - return t.subsets = o, t.subsetsAge = 100, o - }, - C = function() { - function t() { - this.list = [] - } - return t.prototype.add = function(t) { - var e; - (e = this.list).push.apply(e, t) - }, t.prototype.forEach = function(t) { - this.list.forEach((function(e) { - return t(e) - })) - }, t - }(); - - function S(t, e) { - var s, i, n = t; - for (e = e.slice(); e.length > 0;) i = e.splice(0, 1)[0], s = n, n = n.childNodes ? n.childNodes[i] : void 0; - return { - node: n, - parentNode: s, - nodeIndex: i - } - } - - function k(t, e, s) { - return e.forEach((function(e) { - ! function(t, e, s) { - var i, n, a, o; - if (![s._const.addElement, s._const.addTextElement].includes(e[s._const.action])) { - var r = S(t, e[s._const.route]); - n = r.node, a = r.parentNode, o = r.nodeIndex - } - var l, d, c = [], - h = { - diff: e, - node: n - }; - if (s.preVirtualDiffApply(h)) return !0; - switch (e[s._const.action]) { - case s._const.addAttribute: - n.attributes || (n.attributes = {}), n.attributes[e[s._const.name]] = e[s._const.value], "checked" === e[s._const.name] ? n.checked = !0 : "selected" === e[s._const.name] ? n.selected = !0 : "INPUT" === n.nodeName && "value" === e[s._const.name] && (n.value = e[s._const.value]); - break; - case s._const.modifyAttribute: - n.attributes[e[s._const.name]] = e[s._const.newValue]; - break; - case s._const.removeAttribute: - delete n.attributes[e[s._const.name]], 0 === Object.keys(n.attributes).length && delete n.attributes, "checked" === e[s._const.name] ? n.checked = !1 : "selected" === e[s._const.name] ? delete n.selected : "INPUT" === n.nodeName && "value" === e[s._const.name] && delete n.value; - break; - case s._const.modifyTextElement: - n.data = e[s._const.newValue], "TEXTAREA" === a.nodeName && (a.value = e[s._const.newValue]); - break; - case s._const.modifyValue: - n.value = e[s._const.newValue]; - break; - case s._const.modifyComment: - n.data = e[s._const.newValue]; - break; - case s._const.modifyChecked: - n.checked = e[s._const.newValue]; - break; - case s._const.modifySelected: - n.selected = e[s._const.newValue]; - break; - case s._const.replaceElement: - l = x(e[s._const.newValue]), a.childNodes[o] = l; - break; - case s._const.relocateGroup: - n.childNodes.splice(e[s._const.from], e[s._const.groupLength]).reverse().forEach((function(t) { - return n.childNodes.splice(e[s._const.to], 0, t) - })), n.subsets && n.subsets.forEach((function(t) { - if (e[s._const.from] < e[s._const.to] && t.oldValue <= e[s._const.to] && t.oldValue > e[s._const.from]) t.oldValue -= e[s._const.groupLength], (i = t.oldValue + t.length - e[s._const.to]) > 0 && (c.push({ - oldValue: e[s._const.to] + e[s._const.groupLength], - newValue: t.newValue + t.length - i, - length: i - }), t.length -= i); - else if (e[s._const.from] > e[s._const.to] && t.oldValue > e[s._const.to] && t.oldValue < e[s._const.from]) { - var i; - t.oldValue += e[s._const.groupLength], (i = t.oldValue + t.length - e[s._const.to]) > 0 && (c.push({ - oldValue: e[s._const.to] + e[s._const.groupLength], - newValue: t.newValue + t.length - i, - length: i - }), t.length -= i) - } else t.oldValue === e[s._const.from] && (t.oldValue = e[s._const.to]) - })); - break; - case s._const.removeElement: - a.childNodes.splice(o, 1), a.subsets && a.subsets.forEach((function(t) { - t.oldValue > o ? t.oldValue -= 1 : t.oldValue === o ? t.delete = !0 : t.oldValue < o && t.oldValue + t.length > o && (t.oldValue + t.length - 1 === o ? t.length-- : (c.push({ - newValue: t.newValue + o - t.oldValue, - oldValue: o, - length: t.length - o + t.oldValue - 1 - }), t.length = o - t.oldValue)) - })), n = a; - break; - case s._const.addElement: - var u = (d = e[s._const.route].slice()).splice(d.length - 1, 1)[0]; - n = null === (i = S(t, d)) || void 0 === i ? void 0 : i.node, l = x(e[s._const.element]), n.childNodes || (n.childNodes = []), u >= n.childNodes.length ? n.childNodes.push(l) : n.childNodes.splice(u, 0, l), n.subsets && n.subsets.forEach((function(t) { - if (t.oldValue >= u) t.oldValue += 1; - else if (t.oldValue < u && t.oldValue + t.length > u) { - var e = t.oldValue + t.length - u; - c.push({ - newValue: t.newValue + t.length - e, - oldValue: u + 1, - length: e - }), t.length -= e - } - })); - break; - case s._const.removeTextElement: - a.childNodes.splice(o, 1), "TEXTAREA" === a.nodeName && delete a.value, a.subsets && a.subsets.forEach((function(t) { - t.oldValue > o ? t.oldValue -= 1 : t.oldValue === o ? t.delete = !0 : t.oldValue < o && t.oldValue + t.length > o && (t.oldValue + t.length - 1 === o ? t.length-- : (c.push({ - newValue: t.newValue + o - t.oldValue, - oldValue: o, - length: t.length - o + t.oldValue - 1 - }), t.length = o - t.oldValue)) - })), n = a; - break; - case s._const.addTextElement: - var p = (d = e[s._const.route].slice()).splice(d.length - 1, 1)[0]; - l = { - nodeName: "#text", - data: e[s._const.value] - }, (n = S(t, d).node).childNodes || (n.childNodes = []), p >= n.childNodes.length ? n.childNodes.push(l) : n.childNodes.splice(p, 0, l), "TEXTAREA" === n.nodeName && (n.value = e[s._const.newValue]), n.subsets && n.subsets.forEach((function(t) { - if (t.oldValue >= p && (t.oldValue += 1), t.oldValue < p && t.oldValue + t.length > p) { - var e = t.oldValue + t.length - p; - c.push({ - newValue: t.newValue + t.length - e, - oldValue: p + 1, - length: e - }), t.length -= e - } - })); - break; - default: - console.log("unknown action") - } - n.subsets && (n.subsets = n.subsets.filter((function(t) { - return !t.delete && t.oldValue !== t.newValue - })), c.length && (n.subsets = n.subsets.concat(c))), s.postVirtualDiffApply({ - node: h.node, - diff: h.diff, - newNode: l - }) - }(t, e, s) - })), !0 - } - - function T(t, e) { - void 0 === e && (e = { - valueDiffing: !0 - }); - var s = { - nodeName: t.nodeName - }; - if (m(t, "Text", "Comment")) s.data = t.data; - else { - if (t.attributes && t.attributes.length > 0) s.attributes = {}, Array.prototype.slice.call(t.attributes).forEach((function(t) { - return s.attributes[t.name] = t.value - })); - if (t.childNodes && t.childNodes.length > 0) s.childNodes = [], Array.prototype.slice.call(t.childNodes).forEach((function(t) { - return s.childNodes.push(T(t, e)) - })); - e.valueDiffing && (m(t, "HTMLTextAreaElement") && (s.value = t.value), m(t, "HTMLInputElement") && ["radio", "checkbox"].includes(t.type.toLowerCase()) && void 0 !== t.checked ? s.checked = t.checked : m(t, "HTMLButtonElement", "HTMLDataElement", "HTMLInputElement", "HTMLLIElement", "HTMLMeterElement", "HTMLOptionElement", "HTMLProgressElement", "HTMLParamElement") && (s.value = t.value), m(t, "HTMLOptionElement") && (s.selected = t.selected)) - } - return s - } - var A = /<\s*\/*[a-zA-Z:_][a-zA-Z0-9:_\-.]*\s*(?:"[^"]*"['"]*|'[^']*'['"]*|[^'"/>])*\/*\s*>|/g, - L = /\s([^'"/\s><]+?)[\s/>]|([^\s=]+)=\s?(".*?"|'.*?')/g; - - function P(t) { - return t.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&") - } - var R = { - area: !0, - base: !0, - br: !0, - col: !0, - embed: !0, - hr: !0, - img: !0, - input: !0, - keygen: !0, - link: !0, - menuItem: !0, - meta: !0, - param: !0, - source: !0, - track: !0, - wbr: !0 - }, - H = function(t, e) { - var s = { - nodeName: "", - attributes: {} - }, - i = !1, - n = t.match(/<\/?([^\s]+?)[/\s>]/); - if (n && (s.nodeName = e || "svg" === n[1] ? n[1] : n[1].toUpperCase(), (R[n[1]] || "/" === t.charAt(t.length - 2)) && (i = !0), s.nodeName.startsWith("!--"))) { - var a = t.indexOf("--\x3e"); - return { - type: "comment", - node: { - nodeName: "#comment", - data: -1 !== a ? t.slice(4, a) : "" - }, - voidElement: i - } - } - for (var o = new RegExp(L), r = null, l = !1; !l;) - if (null === (r = o.exec(t))) l = !0; - else if (r[0].trim()) - if (r[1]) { - var d = r[1].trim(), - c = [d, ""]; - d.indexOf("=") > -1 && (c = d.split("=")), s.attributes[c[0]] = c[1], o.lastIndex-- - } else r[2] && (s.attributes[r[2]] = r[3].trim().substring(1, r[3].length - 1)); - return { - type: "tag", - node: s, - voidElement: i - } - }, - I = function(t, e) { - void 0 === e && (e = { - valueDiffing: !0, - caseSensitive: !1 - }); - var s, i = [], - n = -1, - a = [], - o = !1; - if (0 !== t.indexOf("<")) { - var r = t.indexOf("<"); - i.push({ - nodeName: "#text", - data: -1 === r ? t : t.substring(0, r) - }) - } - return t.replace(A, (function(r, l) { - var d = "/" !== r.charAt(1), - c = r.startsWith("\x3c!--"), - h = l + r.length, - u = t.charAt(h); - if (c) { - var p = H(r, e.caseSensitive).node; - if (n < 0) return i.push(p), ""; - var f = a[n]; - return f && p.nodeName && (f.node.childNodes || (f.node.childNodes = []), f.node.childNodes.push(p)), "" - } - if (d) { - if ("svg" === (s = H(r, e.caseSensitive || o)).node.nodeName && (o = !0), n++, !s.voidElement && u && "<" !== u) { - s.node.childNodes || (s.node.childNodes = []); - var m = P(t.slice(h, t.indexOf("<", h))); - s.node.childNodes.push({ - nodeName: "#text", - data: m - }), e.valueDiffing && "TEXTAREA" === s.node.nodeName && (s.node.value = m) - } - 0 === n && s.node.nodeName && i.push(s.node); - var g = a[n - 1]; - g && s.node.nodeName && (g.node.childNodes || (g.node.childNodes = []), g.node.childNodes.push(s.node)), a[n] = s - } - if ((!d || s.voidElement) && (n > -1 && (s.voidElement || e.caseSensitive && s.node.nodeName === r.slice(2, -1) || !e.caseSensitive && s.node.nodeName.toUpperCase() === r.slice(2, -1).toUpperCase()) && --n > -1 && ("svg" === s.node.nodeName && (o = !1), s = a[n]), "<" !== u && u)) { - var b = -1 === n ? i : a[n].node.childNodes || [], - v = t.indexOf("<", h); - m = P(t.slice(h, -1 === v ? void 0 : v)); - b.push({ - nodeName: "#text", - data: m - }) - } - return "" - })), i[0] - }, - Y = function() { - function t(t, e, s) { - this.options = s, this.t1 = "undefined" != typeof Element && m(t, "Element") ? T(t, this.options) : "string" == typeof t ? I(t, this.options) : JSON.parse(JSON.stringify(t)), this.t2 = "undefined" != typeof Element && m(e, "Element") ? T(e, this.options) : "string" == typeof e ? I(e, this.options) : JSON.parse(JSON.stringify(e)), this.diffcount = 0, this.foundAll = !1, this.debug && (this.t1Orig = "undefined" != typeof Element && m(t, "Element") ? T(t, this.options) : "string" == typeof t ? I(t, this.options) : JSON.parse(JSON.stringify(t)), this.t2Orig = "undefined" != typeof Element && m(e, "Element") ? T(e, this.options) : "string" == typeof e ? I(e, this.options) : JSON.parse(JSON.stringify(e))), this.tracker = new C - } - return t.prototype.init = function() { - return this.findDiffs(this.t1, this.t2) - }, t.prototype.findDiffs = function(t, e) { - var s; - do { - if (this.options.debug && (this.diffcount += 1, this.diffcount > this.options.diffcap)) throw new Error("surpassed diffcap:".concat(JSON.stringify(this.t1Orig), " -> ").concat(JSON.stringify(this.t2Orig))); - 0 === (s = this.findNextDiff(t, e, [])).length && (O(t, e) || (this.foundAll ? console.error("Could not find remaining diffs!") : (this.foundAll = !0, N(t), s = this.findNextDiff(t, e, [])))), s.length > 0 && (this.foundAll = !1, this.tracker.add(s), k(t, s, this.options)) - } while (s.length > 0); - return this.tracker.list - }, t.prototype.findNextDiff = function(t, e, s) { - var i, n; - if (this.options.maxDepth && s.length > this.options.maxDepth) return []; - if (!t.outerDone) { - if (i = this.findOuterDiff(t, e, s), this.options.filterOuterDiff && (n = this.options.filterOuterDiff(t, e, i)) && (i = n), i.length > 0) return t.outerDone = !0, i; - t.outerDone = !0 - } - if (Object.prototype.hasOwnProperty.call(t, "data")) return []; - if (!t.innerDone) { - if ((i = this.findInnerDiff(t, e, s)).length > 0) return i; - t.innerDone = !0 - } - if (this.options.valueDiffing && !t.valueDone) { - if ((i = this.findValueDiff(t, e, s)).length > 0) return t.valueDone = !0, i; - t.valueDone = !0 - } - return [] - }, t.prototype.findOuterDiff = function(t, e, s) { - var i, n, a, o, r, l, d = []; - if (t.nodeName !== e.nodeName) { - if (!s.length) throw new Error("Top level nodes have to be of the same kind."); - return [(new f).setValue(this.options._const.action, this.options._const.replaceElement).setValue(this.options._const.oldValue, x(t)).setValue(this.options._const.newValue, x(e)).setValue(this.options._const.route, s)] - } - if (s.length && this.options.diffcap < Math.abs((t.childNodes || []).length - (e.childNodes || []).length)) return [(new f).setValue(this.options._const.action, this.options._const.replaceElement).setValue(this.options._const.oldValue, x(t)).setValue(this.options._const.newValue, x(e)).setValue(this.options._const.route, s)]; - if (Object.prototype.hasOwnProperty.call(t, "data") && t.data !== e.data) return "#text" === t.nodeName ? [(new f).setValue(this.options._const.action, this.options._const.modifyTextElement).setValue(this.options._const.route, s).setValue(this.options._const.oldValue, t.data).setValue(this.options._const.newValue, e.data)] : [(new f).setValue(this.options._const.action, this.options._const.modifyComment).setValue(this.options._const.route, s).setValue(this.options._const.oldValue, t.data).setValue(this.options._const.newValue, e.data)]; - for (n = t.attributes ? Object.keys(t.attributes).sort() : [], a = e.attributes ? Object.keys(e.attributes).sort() : [], o = n.length, l = 0; l < o; l++) i = n[l], -1 === (r = a.indexOf(i)) ? d.push((new f).setValue(this.options._const.action, this.options._const.removeAttribute).setValue(this.options._const.route, s).setValue(this.options._const.name, i).setValue(this.options._const.value, t.attributes[i])) : (a.splice(r, 1), t.attributes[i] !== e.attributes[i] && d.push((new f).setValue(this.options._const.action, this.options._const.modifyAttribute).setValue(this.options._const.route, s).setValue(this.options._const.name, i).setValue(this.options._const.oldValue, t.attributes[i]).setValue(this.options._const.newValue, e.attributes[i]))); - for (o = a.length, l = 0; l < o; l++) i = a[l], d.push((new f).setValue(this.options._const.action, this.options._const.addAttribute).setValue(this.options._const.route, s).setValue(this.options._const.name, i).setValue(this.options._const.value, e.attributes[i])); - return d - }, t.prototype.findInnerDiff = function(t, e, s) { - var i = t.childNodes ? t.childNodes.slice() : [], - n = e.childNodes ? e.childNodes.slice() : [], - a = Math.max(i.length, n.length), - o = Math.abs(i.length - n.length), - r = [], - l = 0; - if (!this.options.maxChildCount || a < this.options.maxChildCount) { - var d = Boolean(t.subsets && t.subsetsAge--), - c = d ? t.subsets : t.childNodes && e.childNodes ? $(t, e) : []; - if (c.length > 0 && (r = this.attemptGroupRelocation(t, e, c, s, d)).length > 0) return r - } - for (var h = 0; h < a; h += 1) { - var u = i[h], - p = n[h]; - o && (u && !p ? "#text" === u.nodeName ? (r.push((new f).setValue(this.options._const.action, this.options._const.removeTextElement).setValue(this.options._const.route, s.concat(l)).setValue(this.options._const.value, u.data)), l -= 1) : (r.push((new f).setValue(this.options._const.action, this.options._const.removeElement).setValue(this.options._const.route, s.concat(l)).setValue(this.options._const.element, x(u))), l -= 1) : p && !u && ("#text" === p.nodeName ? r.push((new f).setValue(this.options._const.action, this.options._const.addTextElement).setValue(this.options._const.route, s.concat(l)).setValue(this.options._const.value, p.data)) : r.push((new f).setValue(this.options._const.action, this.options._const.addElement).setValue(this.options._const.route, s.concat(l)).setValue(this.options._const.element, x(p))))), u && p && (!this.options.maxChildCount || a < this.options.maxChildCount ? r = r.concat(this.findNextDiff(u, p, s.concat(l))) : O(u, p) || (i.length > n.length ? ("#text" === u.nodeName ? r.push((new f).setValue(this.options._const.action, this.options._const.removeTextElement).setValue(this.options._const.route, s.concat(l)).setValue(this.options._const.value, u.data)) : r.push((new f).setValue(this.options._const.action, this.options._const.removeElement).setValue(this.options._const.element, x(u)).setValue(this.options._const.route, s.concat(l))), i.splice(h, 1), h -= 1, l -= 1, o -= 1) : i.length < n.length ? (r = r.concat([(new f).setValue(this.options._const.action, this.options._const.addElement).setValue(this.options._const.element, x(p)).setValue(this.options._const.route, s.concat(l))]), i.splice(h, 0, x(p)), o -= 1) : r = r.concat([(new f).setValue(this.options._const.action, this.options._const.replaceElement).setValue(this.options._const.oldValue, x(u)).setValue(this.options._const.newValue, x(p)).setValue(this.options._const.route, s.concat(l))]))), l += 1 - } - return t.innerDone = !0, r - }, t.prototype.attemptGroupRelocation = function(t, e, s, i, n) { - for (var a, o, r, l, d, c = function(t, e, s) { - var i = t.childNodes ? V(t.childNodes.length, !0) : [], - n = e.childNodes ? V(e.childNodes.length, !0) : [], - a = 0; - return s.forEach((function(t) { - for (var e = t.oldValue + t.length, s = t.newValue + t.length, o = t.oldValue; o < e; o += 1) i[o] = a; - for (o = t.newValue; o < s; o += 1) n[o] = a; - a += 1 - })), { - gaps1: i, - gaps2: n - } - }(t, e, s), h = c.gaps1, u = c.gaps2, p = t.childNodes.slice(), m = e.childNodes.slice(), g = Math.min(h.length, u.length), b = [], v = 0, _ = 0; v < g; _ += 1, v += 1) - if (!n || !0 !== h[v] && !0 !== u[v]) { - if (!0 === h[_]) - if ("#text" === (l = p[_]).nodeName) - if ("#text" === m[v].nodeName) { - if (l.data !== m[v].data) { - for (var w = _; p.length > w + 1 && "#text" === p[w + 1].nodeName;) - if (w += 1, m[v].data === p[w].data) { - d = !0; - break - } d || b.push((new f).setValue(this.options._const.action, this.options._const.modifyTextElement).setValue(this.options._const.route, i.concat(_)).setValue(this.options._const.oldValue, l.data).setValue(this.options._const.newValue, m[v].data)) - } - } else b.push((new f).setValue(this.options._const.action, this.options._const.removeTextElement).setValue(this.options._const.route, i.concat(_)).setValue(this.options._const.value, l.data)), h.splice(_, 1), p.splice(_, 1), g = Math.min(h.length, u.length), _ -= 1, v -= 1; - else !0 === u[v] ? b.push((new f).setValue(this.options._const.action, this.options._const.replaceElement).setValue(this.options._const.oldValue, x(l)).setValue(this.options._const.newValue, x(m[v])).setValue(this.options._const.route, i.concat(_))) : (b.push((new f).setValue(this.options._const.action, this.options._const.removeElement).setValue(this.options._const.route, i.concat(_)).setValue(this.options._const.element, x(l))), h.splice(_, 1), p.splice(_, 1), g = Math.min(h.length, u.length), _ -= 1, v -= 1); - else if (!0 === u[v]) "#text" === (l = m[v]).nodeName ? (b.push((new f).setValue(this.options._const.action, this.options._const.addTextElement).setValue(this.options._const.route, i.concat(_)).setValue(this.options._const.value, l.data)), h.splice(_, 0, !0), p.splice(_, 0, { - nodeName: "#text", - data: l.data - }), g = Math.min(h.length, u.length)) : (b.push((new f).setValue(this.options._const.action, this.options._const.addElement).setValue(this.options._const.route, i.concat(_)).setValue(this.options._const.element, x(l))), h.splice(_, 0, !0), p.splice(_, 0, x(l)), g = Math.min(h.length, u.length)); - else if (h[_] !== u[v]) { - if (b.length > 0) return b; - if (r = s[h[_]], (o = Math.min(r.newValue, p.length - r.length)) !== r.oldValue) { - a = !1; - for (var y = 0; y < r.length; y += 1) E(p[o + y], p[r.oldValue + y], {}, !1, !0) || (a = !0); - if (a) return [(new f).setValue(this.options._const.action, this.options._const.relocateGroup).setValue(this.options._const.groupLength, r.length).setValue(this.options._const.from, r.oldValue).setValue(this.options._const.to, o).setValue(this.options._const.route, i)] - } - } - } else; - return b - }, t.prototype.findValueDiff = function(t, e, s) { - var i = []; - return t.selected !== e.selected && i.push((new f).setValue(this.options._const.action, this.options._const.modifySelected).setValue(this.options._const.oldValue, t.selected).setValue(this.options._const.newValue, e.selected).setValue(this.options._const.route, s)), (t.value || e.value) && t.value !== e.value && "OPTION" !== t.nodeName && i.push((new f).setValue(this.options._const.action, this.options._const.modifyValue).setValue(this.options._const.oldValue, t.value || "").setValue(this.options._const.newValue, e.value || "").setValue(this.options._const.route, s)), t.checked !== e.checked && i.push((new f).setValue(this.options._const.action, this.options._const.modifyChecked).setValue(this.options._const.oldValue, t.checked).setValue(this.options._const.newValue, e.checked).setValue(this.options._const.route, s)), i - }, t - }(), - j = { - debug: !1, - diffcap: 10, - maxDepth: !1, - maxChildCount: 50, - valueDiffing: !0, - textDiff: function(t, e, s, i) { - t.data = i - }, - preVirtualDiffApply: function() {}, - postVirtualDiffApply: function() {}, - preDiffApply: function() {}, - postDiffApply: function() {}, - filterOuterDiff: null, - compress: !1, - _const: !1, - document: !("undefined" == typeof window || !window.document) && window.document, - components: [] - }, - F = function() { - function t(t) { - if (void 0 === t && (t = {}), Object.entries(j).forEach((function(e) { - var s = e[0], - i = e[1]; - Object.prototype.hasOwnProperty.call(t, s) || (t[s] = i) - })), !t._const) { - var e = ["addAttribute", "modifyAttribute", "removeAttribute", "modifyTextElement", "relocateGroup", "removeElement", "addElement", "removeTextElement", "addTextElement", "replaceElement", "modifyValue", "modifyChecked", "modifySelected", "modifyComment", "action", "route", "oldValue", "newValue", "element", "group", "groupLength", "from", "to", "name", "value", "data", "attributes", "nodeName", "childNodes", "checked", "selected"], - s = {}; - t.compress ? e.forEach((function(t, e) { - return s[t] = e - })) : e.forEach((function(t) { - return s[t] = t - })), t._const = s - } - this.options = t - } - return t.prototype.apply = function(t, e) { - return function(t, e, s) { - return e.every((function(e) { - return v(t, e, s) - })) - }(t, e, this.options) - }, t.prototype.undo = function(t, e) { - return w(t, e, this.options) - }, t.prototype.diff = function(t, e) { - return new Y(t, e, this.options).init() - }, t - }(); - const q = (t, e, s, { - classes: i, - format: n, - hiddenHeader: a, - sortable: o, - scrollY: r, - type: l - }, { - noColumnWidths: d, - unhideHeader: h - }) => ({ - nodeName: "TR", - childNodes: t.map(((t, u) => { - const p = e[u] || { - type: l, - format: n, - sortable: !0, - searchable: !0 - }; - if (p.hidden) return; - const f = t.attributes ? { - ...t.attributes - } : {}; - if (p.sortable && o && (!r.length || h) && (p.filter ? f["data-filterable"] = "true" : f["data-sortable"] = "true"), p.headerClass && (f.class = c(f.class, p.headerClass)), s.sort && s.sort.column === u) { - const t = "asc" === s.sort.dir ? i.ascending : i.descending; - f.class = c(f.class, t), f["aria-sort"] = "asc" === s.sort.dir ? "ascending" : "descending" - } else s.filters[u] && (f.class = c(f.class, i.filterActive)); - if (s.widths[u] && !d) { - const t = `width: ${s.widths[u]}%;`; - f.style = c(f.style, t) - } - if (r.length && !h) { - const t = "padding-bottom: 0;padding-top: 0;border: 0;"; - f.style = c(f.style, t) - } - const m = "html" === t.type ? t.data : [{ - nodeName: "#text", - data: t.text ?? String(t.data) - }]; - return { - nodeName: "TH", - attributes: f, - childNodes: !a && !r.length || h ? p.sortable && o ? [{ - nodeName: "BUTTON", - attributes: { - class: p.filter ? i.filter : i.sorter - }, - childNodes: m - }] : m : [{ - nodeName: "#text", - data: "" - }] - } - })).filter((t => t)) - }), - B = (t, e, s, i, a, o, { - classes: r, - hiddenHeader: l, - header: d, - footer: h, - format: u, - sortable: p, - scrollY: f, - type: m, - rowRender: g, - tabIndex: b - }, { - noColumnWidths: v, - unhideHeader: _, - renderHeader: w - }, y, M) => { - const D = { - nodeName: "TABLE", - attributes: { - ...t - }, - childNodes: [{ - nodeName: "TBODY", - childNodes: s.map((({ - row: t, - index: e - }) => { - const s = { - nodeName: "TR", - attributes: { - ...t.attributes, - "data-index": String(e) - }, - childNodes: t.cells.map(((t, s) => { - const o = i[s] || { - type: m, - format: u, - sortable: !0, - searchable: !0 - }; - if (o.hidden) return; - const r = { - nodeName: "TD", - attributes: t.attributes ? { - ...t.attributes - } : {}, - childNodes: "html" === o.type ? t.data : [{ - nodeName: "#text", - data: n(t) - }] - }; - if (d || h || !a.widths[s] || v || (r.attributes.style = c(r.attributes.style, `width: ${a.widths[s]}%;`)), o.cellClass && (r.attributes.class = c(r.attributes.class, o.cellClass)), o.render) { - const i = o.render(t.data, r, e, s); - if (i) { - if ("string" != typeof i) return i; - { - const t = I(`${i}`); - 1 === t.childNodes.length && ["#text", "#comment"].includes(t.childNodes[0].nodeName) ? r.childNodes[0].data = i : r.childNodes = t.childNodes - } - } - } - return r - })).filter((t => t)) - }; - if (e === o && (s.attributes.class = c(s.attributes.class, r.cursor)), g) { - const i = g(t, s, e); - if (i) { - if ("string" != typeof i) return i; - { - const t = I(`${i}`); - !t.childNodes || 1 === t.childNodes.length && ["#text", "#comment"].includes(t.childNodes[0].nodeName) ? s.childNodes[0].data = i : s.childNodes = t.childNodes - } - } - } - return s - })) - }] - }; - if (D.attributes.class = c(D.attributes.class, r.table), d || h || w) { - const t = q(e, i, a, { - classes: r, - hiddenHeader: l, - sortable: p, - scrollY: f - }, { - noColumnWidths: v, - unhideHeader: _ - }); - if (d || w) { - const e = { - nodeName: "THEAD", - childNodes: [t] - }; - !f.length && !l || _ || (e.attributes = { - style: "height: 0px;" - }), D.childNodes.unshift(e) - } - if (h) { - const e = { - nodeName: "TFOOT", - childNodes: [d ? structuredClone(t) : t] - }; - !f.length && !l || _ || (e.attributes = { - style: "height: 0px;" - }), D.childNodes.push(e) - } - } - return y.forEach((t => D.childNodes.push(t))), M.forEach((t => D.childNodes.push(t))), !1 !== b && (D.attributes.tabindex = String(b)), D - }; - "undefined" != typeof globalThis ? globalThis : "undefined" != typeof window ? window : "undefined" != typeof global ? global : "undefined" != typeof self && self; - - function z(t) { - return t && t.__esModule && Object.prototype.hasOwnProperty.call(t, "default") ? t.default : t - } - var U = { - exports: {} - }, - J = z(U.exports = function() { - var t = 1e3, - e = 6e4, - s = 36e5, - i = "millisecond", - n = "second", - a = "minute", - o = "hour", - r = "day", - l = "week", - d = "month", - c = "quarter", - h = "year", - u = "date", - p = "Invalid Date", - f = /^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[Tt\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/, - m = /\[([^\]]+)]|Y{1,4}|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|a|A|m{1,2}|s{1,2}|Z{1,2}|SSS/g, - g = { - name: "en", - weekdays: "Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"), - months: "January_February_March_April_May_June_July_August_September_October_November_December".split("_"), - ordinal: function(t) { - var e = ["th", "st", "nd", "rd"], - s = t % 100; - return "[" + t + (e[(s - 20) % 10] || e[s] || e[0]) + "]" - } - }, - b = function(t, e, s) { - var i = String(t); - return !i || i.length >= e ? t : "" + Array(e + 1 - i.length).join(s) + t - }, - v = { - s: b, - z: function(t) { - var e = -t.utcOffset(), - s = Math.abs(e), - i = Math.floor(s / 60), - n = s % 60; - return (e <= 0 ? "+" : "-") + b(i, 2, "0") + ":" + b(n, 2, "0") - }, - m: function t(e, s) { - if (e.date() < s.date()) return -t(s, e); - var i = 12 * (s.year() - e.year()) + (s.month() - e.month()), - n = e.clone().add(i, d), - a = s - n < 0, - o = e.clone().add(i + (a ? -1 : 1), d); - return +(-(i + (s - n) / (a ? n - o : o - n)) || 0) - }, - a: function(t) { - return t < 0 ? Math.ceil(t) || 0 : Math.floor(t) - }, - p: function(t) { - return { - M: d, - y: h, - w: l, - d: r, - D: u, - h: o, - m: a, - s: n, - ms: i, - Q: c - } [t] || String(t || "").toLowerCase().replace(/s$/, "") - }, - u: function(t) { - return void 0 === t - } - }, - _ = "en", - w = {}; - w[_] = g; - var y = "$isDayjsObject", - M = function(t) { - return t instanceof O || !(!t || !t[y]) - }, - D = function t(e, s, i) { - var n; - if (!e) return _; - if ("string" == typeof e) { - var a = e.toLowerCase(); - w[a] && (n = a), s && (w[a] = s, n = a); - var o = e.split("-"); - if (!n && o.length > 1) return t(o[0]) - } else { - var r = e.name; - w[r] = e, n = r - } - return !i && n && (_ = n), n || !i && _ - }, - N = function(t, e) { - if (M(t)) return t.clone(); - var s = "object" == typeof e ? e : {}; - return s.date = t, s.args = arguments, new O(s) - }, - x = v; - x.l = D, x.i = M, x.w = function(t, e) { - return N(t, { - locale: e.$L, - utc: e.$u, - x: e.$x, - $offset: e.$offset - }) - }; - var O = function() { - function g(t) { - this.$L = D(t.locale, null, !0), this.parse(t), this.$x = this.$x || t.x || {}, this[y] = !0 - } - var b = g.prototype; - return b.parse = function(t) { - this.$d = function(t) { - var e = t.date, - s = t.utc; - if (null === e) return new Date(NaN); - if (x.u(e)) return new Date; - if (e instanceof Date) return new Date(e); - if ("string" == typeof e && !/Z$/i.test(e)) { - var i = e.match(f); - if (i) { - var n = i[2] - 1 || 0, - a = (i[7] || "0").substring(0, 3); - return s ? new Date(Date.UTC(i[1], n, i[3] || 1, i[4] || 0, i[5] || 0, i[6] || 0, a)) : new Date(i[1], n, i[3] || 1, i[4] || 0, i[5] || 0, i[6] || 0, a) - } - } - return new Date(e) - }(t), this.init() - }, b.init = function() { - var t = this.$d; - this.$y = t.getFullYear(), this.$M = t.getMonth(), this.$D = t.getDate(), this.$W = t.getDay(), this.$H = t.getHours(), this.$m = t.getMinutes(), this.$s = t.getSeconds(), this.$ms = t.getMilliseconds() - }, b.$utils = function() { - return x - }, b.isValid = function() { - return !(this.$d.toString() === p) - }, b.isSame = function(t, e) { - var s = N(t); - return this.startOf(e) <= s && s <= this.endOf(e) - }, b.isAfter = function(t, e) { - return N(t) < this.startOf(e) - }, b.isBefore = function(t, e) { - return this.endOf(e) < N(t) - }, b.$g = function(t, e, s) { - return x.u(t) ? this[e] : this.set(s, t) - }, b.unix = function() { - return Math.floor(this.valueOf() / 1e3) - }, b.valueOf = function() { - return this.$d.getTime() - }, b.startOf = function(t, e) { - var s = this, - i = !!x.u(e) || e, - c = x.p(t), - p = function(t, e) { - var n = x.w(s.$u ? Date.UTC(s.$y, e, t) : new Date(s.$y, e, t), s); - return i ? n : n.endOf(r) - }, - f = function(t, e) { - return x.w(s.toDate()[t].apply(s.toDate("s"), (i ? [0, 0, 0, 0] : [23, 59, 59, 999]).slice(e)), s) - }, - m = this.$W, - g = this.$M, - b = this.$D, - v = "set" + (this.$u ? "UTC" : ""); - switch (c) { - case h: - return i ? p(1, 0) : p(31, 11); - case d: - return i ? p(1, g) : p(0, g + 1); - case l: - var _ = this.$locale().weekStart || 0, - w = (m < _ ? m + 7 : m) - _; - return p(i ? b - w : b + (6 - w), g); - case r: - case u: - return f(v + "Hours", 0); - case o: - return f(v + "Minutes", 1); - case a: - return f(v + "Seconds", 2); - case n: - return f(v + "Milliseconds", 3); - default: - return this.clone() - } - }, b.endOf = function(t) { - return this.startOf(t, !1) - }, b.$set = function(t, e) { - var s, l = x.p(t), - c = "set" + (this.$u ? "UTC" : ""), - p = (s = {}, s[r] = c + "Date", s[u] = c + "Date", s[d] = c + "Month", s[h] = c + "FullYear", s[o] = c + "Hours", s[a] = c + "Minutes", s[n] = c + "Seconds", s[i] = c + "Milliseconds", s)[l], - f = l === r ? this.$D + (e - this.$W) : e; - if (l === d || l === h) { - var m = this.clone().set(u, 1); - m.$d[p](f), m.init(), this.$d = m.set(u, Math.min(this.$D, m.daysInMonth())).$d - } else p && this.$d[p](f); - return this.init(), this - }, b.set = function(t, e) { - return this.clone().$set(t, e) - }, b.get = function(t) { - return this[x.p(t)]() - }, b.add = function(i, c) { - var u, p = this; - i = Number(i); - var f = x.p(c), - m = function(t) { - var e = N(p); - return x.w(e.date(e.date() + Math.round(t * i)), p) - }; - if (f === d) return this.set(d, this.$M + i); - if (f === h) return this.set(h, this.$y + i); - if (f === r) return m(1); - if (f === l) return m(7); - var g = (u = {}, u[a] = e, u[o] = s, u[n] = t, u)[f] || 1, - b = this.$d.getTime() + i * g; - return x.w(b, this) - }, b.subtract = function(t, e) { - return this.add(-1 * t, e) - }, b.format = function(t) { - var e = this, - s = this.$locale(); - if (!this.isValid()) return s.invalidDate || p; - var i = t || "YYYY-MM-DDTHH:mm:ssZ", - n = x.z(this), - a = this.$H, - o = this.$m, - r = this.$M, - l = s.weekdays, - d = s.months, - c = s.meridiem, - h = function(t, s, n, a) { - return t && (t[s] || t(e, i)) || n[s].slice(0, a) - }, - u = function(t) { - return x.s(a % 12 || 12, t, "0") - }, - f = c || function(t, e, s) { - var i = t < 12 ? "AM" : "PM"; - return s ? i.toLowerCase() : i - }; - return i.replace(m, (function(t, i) { - return i || function(t) { - switch (t) { - case "YY": - return String(e.$y).slice(-2); - case "YYYY": - return x.s(e.$y, 4, "0"); - case "M": - return r + 1; - case "MM": - return x.s(r + 1, 2, "0"); - case "MMM": - return h(s.monthsShort, r, d, 3); - case "MMMM": - return h(d, r); - case "D": - return e.$D; - case "DD": - return x.s(e.$D, 2, "0"); - case "d": - return String(e.$W); - case "dd": - return h(s.weekdaysMin, e.$W, l, 2); - case "ddd": - return h(s.weekdaysShort, e.$W, l, 3); - case "dddd": - return l[e.$W]; - case "H": - return String(a); - case "HH": - return x.s(a, 2, "0"); - case "h": - return u(1); - case "hh": - return u(2); - case "a": - return f(a, o, !0); - case "A": - return f(a, o, !1); - case "m": - return String(o); - case "mm": - return x.s(o, 2, "0"); - case "s": - return String(e.$s); - case "ss": - return x.s(e.$s, 2, "0"); - case "SSS": - return x.s(e.$ms, 3, "0"); - case "Z": - return n - } - return null - }(t) || n.replace(":", "") - })) - }, b.utcOffset = function() { - return 15 * -Math.round(this.$d.getTimezoneOffset() / 15) - }, b.diff = function(i, u, p) { - var f, m = this, - g = x.p(u), - b = N(i), - v = (b.utcOffset() - this.utcOffset()) * e, - _ = this - b, - w = function() { - return x.m(m, b) - }; - switch (g) { - case h: - f = w() / 12; - break; - case d: - f = w(); - break; - case c: - f = w() / 3; - break; - case l: - f = (_ - v) / 6048e5; - break; - case r: - f = (_ - v) / 864e5; - break; - case o: - f = _ / s; - break; - case a: - f = _ / e; - break; - case n: - f = _ / t; - break; - default: - f = _ - } - return p ? f : x.a(f) - }, b.daysInMonth = function() { - return this.endOf(d).$D - }, b.$locale = function() { - return w[this.$L] - }, b.locale = function(t, e) { - if (!t) return this.$L; - var s = this.clone(), - i = D(t, e, !0); - return i && (s.$L = i), s - }, b.clone = function() { - return x.w(this.$d, this) - }, b.toDate = function() { - return new Date(this.valueOf()) - }, b.toJSON = function() { - return this.isValid() ? this.toISOString() : null - }, b.toISOString = function() { - return this.$d.toISOString() - }, b.toString = function() { - return this.$d.toUTCString() - }, g - }(), - E = O.prototype; - return N.prototype = E, [ - ["$ms", i], - ["$s", n], - ["$m", a], - ["$H", o], - ["$W", r], - ["$M", d], - ["$y", h], - ["$D", u] - ].forEach((function(t) { - E[t[1]] = function(e) { - return this.$g(e, t[0], t[1]) - } - })), N.extend = function(t, e) { - return t.$i || (t(e, O, N), t.$i = !0), N - }, N.locale = D, N.isDayjs = M, N.unix = function(t) { - return N(1e3 * t) - }, N.en = w[_], N.Ls = w, N.p = {}, N - }()), - W = { - exports: {} - }, - Q = z(W.exports = function() { - var t = { - LTS: "h:mm:ss A", - LT: "h:mm A", - L: "MM/DD/YYYY", - LL: "MMMM D, YYYY", - LLL: "MMMM D, YYYY h:mm A", - LLLL: "dddd, MMMM D, YYYY h:mm A" - }, - e = /(\[[^[]*\])|([-_:/.,()\s]+)|(A|a|YYYY|YY?|MM?M?M?|Do|DD?|hh?|HH?|mm?|ss?|S{1,3}|z|ZZ?)/g, - s = /\d\d/, - i = /\d\d?/, - n = /\d*[^-_:/,()\s\d]+/, - a = {}, - o = function(t) { - return (t = +t) + (t > 68 ? 1900 : 2e3) - }, - r = function(t) { - return function(e) { - this[t] = +e - } - }, - l = [/[+-]\d\d:?(\d\d)?|Z/, function(t) { - (this.zone || (this.zone = {})).offset = function(t) { - if (!t) return 0; - if ("Z" === t) return 0; - var e = t.match(/([+-]|\d\d)/g), - s = 60 * e[1] + (+e[2] || 0); - return 0 === s ? 0 : "+" === e[0] ? -s : s - }(t) - }], - d = function(t) { - var e = a[t]; - return e && (e.indexOf ? e : e.s.concat(e.f)) - }, - c = function(t, e) { - var s, i = a.meridiem; - if (i) { - for (var n = 1; n <= 24; n += 1) - if (t.indexOf(i(n, 0, e)) > -1) { - s = n > 12; - break - } - } else s = t === (e ? "pm" : "PM"); - return s - }, - h = { - A: [n, function(t) { - this.afternoon = c(t, !1) - }], - a: [n, function(t) { - this.afternoon = c(t, !0) - }], - S: [/\d/, function(t) { - this.milliseconds = 100 * +t - }], - SS: [s, function(t) { - this.milliseconds = 10 * +t - }], - SSS: [/\d{3}/, function(t) { - this.milliseconds = +t - }], - s: [i, r("seconds")], - ss: [i, r("seconds")], - m: [i, r("minutes")], - mm: [i, r("minutes")], - H: [i, r("hours")], - h: [i, r("hours")], - HH: [i, r("hours")], - hh: [i, r("hours")], - D: [i, r("day")], - DD: [s, r("day")], - Do: [n, function(t) { - var e = a.ordinal, - s = t.match(/\d+/); - if (this.day = s[0], e) - for (var i = 1; i <= 31; i += 1) e(i).replace(/\[|\]/g, "") === t && (this.day = i) - }], - M: [i, r("month")], - MM: [s, r("month")], - MMM: [n, function(t) { - var e = d("months"), - s = (d("monthsShort") || e.map((function(t) { - return t.slice(0, 3) - }))).indexOf(t) + 1; - if (s < 1) throw new Error; - this.month = s % 12 || s - }], - MMMM: [n, function(t) { - var e = d("months").indexOf(t) + 1; - if (e < 1) throw new Error; - this.month = e % 12 || e - }], - Y: [/[+-]?\d+/, r("year")], - YY: [s, function(t) { - this.year = o(t) - }], - YYYY: [/\d{4}/, r("year")], - Z: l, - ZZ: l - }; - - function u(s) { - var i, n; - i = s, n = a && a.formats; - for (var o = (s = i.replace(/(\[[^\]]+])|(LTS?|l{1,4}|L{1,4})/g, (function(e, s, i) { - var a = i && i.toUpperCase(); - return s || n[i] || t[i] || n[a].replace(/(\[[^\]]+])|(MMMM|MM|DD|dddd)/g, (function(t, e, s) { - return e || s.slice(1) - })) - }))).match(e), r = o.length, l = 0; l < r; l += 1) { - var d = o[l], - c = h[d], - u = c && c[0], - p = c && c[1]; - o[l] = p ? { - regex: u, - parser: p - } : d.replace(/^\[|\]$/g, "") - } - return function(t) { - for (var e = {}, s = 0, i = 0; s < r; s += 1) { - var n = o[s]; - if ("string" == typeof n) i += n.length; - else { - var a = n.regex, - l = n.parser, - d = t.slice(i), - c = a.exec(d)[0]; - l.call(e, c), t = t.replace(c, "") - } - } - return function(t) { - var e = t.afternoon; - if (void 0 !== e) { - var s = t.hours; - e ? s < 12 && (t.hours += 12) : 12 === s && (t.hours = 0), delete t.afternoon - } - }(e), e - } - } - return function(t, e, s) { - s.p.customParseFormat = !0, t && t.parseTwoDigitYear && (o = t.parseTwoDigitYear); - var i = e.prototype, - n = i.parse; - i.parse = function(t) { - var e = t.date, - i = t.utc, - o = t.args; - this.$u = i; - var r = o[1]; - if ("string" == typeof r) { - var l = !0 === o[2], - d = !0 === o[3], - c = l || d, - h = o[2]; - d && (h = o[2]), a = this.$locale(), !l && h && (a = s.Ls[h]), this.$d = function(t, e, s) { - try { - if (["x", "X"].indexOf(e) > -1) return new Date(("X" === e ? 1e3 : 1) * t); - var i = u(e)(t), - n = i.year, - a = i.month, - o = i.day, - r = i.hours, - l = i.minutes, - d = i.seconds, - c = i.milliseconds, - h = i.zone, - p = new Date, - f = o || (n || a ? 1 : p.getDate()), - m = n || p.getFullYear(), - g = 0; - n && !a || (g = a > 0 ? a - 1 : p.getMonth()); - var b = r || 0, - v = l || 0, - _ = d || 0, - w = c || 0; - return h ? new Date(Date.UTC(m, g, f, b, v, _, w + 60 * h.offset * 1e3)) : s ? new Date(Date.UTC(m, g, f, b, v, _, w)) : new Date(m, g, f, b, v, _, w) - } catch (t) { - return new Date("") - } - }(e, r, i), this.init(), h && !0 !== h && (this.$L = this.locale(h).$L), c && e != this.format(r) && (this.$d = new Date("")), a = {} - } else if (r instanceof Array) - for (var p = r.length, f = 1; f <= p; f += 1) { - o[1] = r[f - 1]; - var m = s.apply(this, o); - if (m.isValid()) { - this.$d = m.$d, this.$L = m.$L, this.init(); - break - } - f === p && (this.$d = new Date("")) - } else n.call(this, t) - } - } - }()); - J.extend(Q); - const X = (t, e) => { - let s; - if (e) switch (e) { - case "ISO_8601": - s = t; - break; - case "RFC_2822": - s = J(t.slice(5), "DD MMM YYYY HH:mm:ss ZZ").unix(); - break; - case "MYSQL": - s = J(t, "YYYY-MM-DD hh:mm:ss").unix(); - break; - case "UNIX": - s = J(t).unix(); - break; - default: - s = J(t, e, !0).valueOf() - } - return s - }, - Z = (t, e) => { - if (t?.constructor === Object && Object.prototype.hasOwnProperty.call(t, "data") && !Object.keys(t).find((t => !["text", "order", "data", "attributes"].includes(t)))) return t; - const s = { - data: t - }; - switch (e.type) { - case "string": - "string" != typeof t && (s.text = String(s.data), s.order = s.text); - break; - case "date": - e.format && (s.order = X(String(s.data), e.format)); - break; - case "number": - s.text = String(s.data), s.data = parseFloat(s.data), s.order = s.data; - break; - case "html": { - const t = Array.isArray(s.data) ? { - nodeName: "TD", - childNodes: s.data - } : I(`${String(s.data)}`); - s.data = t.childNodes || []; - const e = i(t); - s.text = e, s.order = e; - break - } - case "boolean": - "string" == typeof s.data && (s.data = s.data.toLowerCase().trim()), s.data = !["false", !1, null, void 0, 0].includes(s.data), s.order = s.data ? 1 : 0, s.text = String(s.data); - break; - case "other": - s.text = "", s.order = 0; - break; - default: - s.text = JSON.stringify(s.data) - } - return s - }, - G = t => { - if (t instanceof Object && t.constructor === Object && t.hasOwnProperty("data") && ("string" == typeof t.text || "string" == typeof t.data)) return t; - const e = { - data: t - }; - if ("string" == typeof t) { - if (t.length) { - const s = I(`${t}`); - if (s.childNodes && (1 !== s.childNodes.length || "#text" !== s.childNodes[0].nodeName)) { - e.data = s.childNodes, e.type = "html"; - const t = i(s); - e.text = t - } - } - } else [null, void 0].includes(t) ? e.text = "" : e.text = JSON.stringify(t); - return e - }, - K = (t, e = void 0, s, n, a) => { - const o = { - data: [], - headings: [] - }; - if (t.headings) o.headings = t.headings.map((t => G(t))); - else if (e?.tHead) o.headings = Array.from(e.tHead.querySelectorAll("th")).map(((t, e) => { - const o = (t => { - const e = T(t, { - valueDiffing: !1 - }); - let s; - return s = !e.childNodes || 1 === e.childNodes.length && "#text" === e.childNodes[0].nodeName ? { - data: t.innerText, - type: "string" - } : { - data: e.childNodes, - type: "html", - text: i(e) - }, s.attributes = e.attributes, s - })(t); - s[e] || (s[e] = { - type: n, - format: a, - searchable: !0, - sortable: !0 - }); - const r = s[e]; - return "false" !== t.dataset.sortable?.trim().toLowerCase() && "false" !== t.dataset.sort?.trim().toLowerCase() || (r.sortable = !1), "false" === t.dataset.searchable?.trim().toLowerCase() && (r.searchable = !1), "true" !== t.dataset.hidden?.trim().toLowerCase() && "true" !== t.getAttribute("hidden")?.trim().toLowerCase() || (r.hidden = !0), ["number", "string", "html", "date", "boolean", "other"].includes(t.dataset.type) && (r.type = t.dataset.type, "date" === r.type && t.dataset.format && (r.format = t.dataset.format)), o - })); - else if (t.data?.length) { - const e = t.data[0], - s = Array.isArray(e) ? e : e.cells; - o.headings = s.map((t => G(""))) - } else e?.tBodies.length && (o.headings = Array.from(e.tBodies[0].rows[0].cells).map((t => G("")))); - for (let t = 0; t < o.headings.length; t++) s[t] || (s[t] = { - type: n, - format: a, - sortable: !0, - searchable: !0 - }); - if (t.data) { - const e = o.headings.map((t => t.data ? String(t.data) : t.text)); - o.data = t.data.map((t => { - let i, n; - return Array.isArray(t) ? (i = {}, n = t) : t.hasOwnProperty("cells") && Object.keys(t).every((t => ["cells", "attributes"].includes(t))) ? (i = t.attributes, n = t.cells) : (i = {}, n = [], Object.entries(t).forEach((([t, s]) => { - const i = e.indexOf(t); - i > -1 && (n[i] = s) - }))), { - attributes: i, - cells: n.map(((t, e) => Z(t, s[e]))) - } - })) - } else e?.tBodies?.length && (o.data = Array.from(e.tBodies[0].rows).map((t => ({ - attributes: r(t.attributes), - cells: Array.from(t.cells).map(((t, e) => { - const i = t.dataset.content ? Z(t.dataset.content, s[e]) : ((t, e) => { - let s; - switch (e.type) { - case "string": - s = { - data: t.innerText - }; - break; - case "date": { - const i = t.innerText; - s = { - data: i, - order: X(i, e.format) - }; - break - } - case "number": { - const e = parseFloat(t.innerText); - s = { - data: e, - order: e, - text: t.innerText - }; - break - } - case "boolean": { - const e = !["false", "0", "null", "undefined"].includes(t.innerText.toLowerCase().trim()); - s = { - data: e, - text: e ? "1" : "0", - order: e ? 1 : 0 - }; - break - } - default: - s = { - data: T(t, { - valueDiffing: !1 - }).childNodes || [], - text: t.innerText, - order: t.innerText - } - } - return s.attributes = r(t.attributes), s - })(t, s[e]); - return t.dataset.order && (i.order = isNaN(parseFloat(t.dataset.order)) ? t.dataset.order : parseFloat(t.dataset.order)), i - })) - })))); - if (o.data.length && o.data[0].cells.length !== o.headings.length) throw new Error("Data heading length mismatch."); - return o - }; - class tt { - cursor; - dt; - constructor(t) { - this.dt = t, this.cursor = !1 - } - setCursor(t = !1) { - if (t === this.cursor) return; - const e = this.cursor; - if (this.cursor = t, this.dt._renderTable(), !1 !== t && this.dt.options.scrollY) { - const t = l(this.dt.options.classes.cursor), - e = this.dt.dom.querySelector(`tr${t}`); - e && e.scrollIntoView({ - block: "nearest" - }) - } - this.dt.emit("datatable.cursormove", this.cursor, e) - } - add(t) { - if (!Array.isArray(t) || t.length < 1) return; - const e = { - cells: t.map(((t, e) => { - const s = this.dt.columns.settings[e]; - return Z(t, s) - })) - }; - this.dt.data.data.push(e), this.dt.hasRows = !0, this.dt.update(!0) - } - remove(t) { - if (!Array.isArray(t)) return this.remove([t]); - this.dt.data.data = this.dt.data.data.filter(((e, s) => !t.includes(s))), this.dt.data.data.length || (this.dt.hasRows = !1), this.dt.update(!0) - } - findRowIndex(t, e) { - return this.dt.data.data.findIndex((s => { - const i = s.cells[t]; - return n(i).toLowerCase().includes(String(e).toLowerCase()) - })) - } - findRow(t, e) { - const s = this.findRowIndex(t, e); - if (s < 0) return { - index: -1, - row: null, - cols: [] - }; - const i = this.dt.data.data[s], - n = i.cells.map((t => t.data)); - return { - index: s, - row: i, - cols: n - } - } - updateRow(t, e) { - const s = { - cells: e.map(((t, e) => { - const s = this.dt.columns.settings[e]; - return Z(t, s) - })) - }; - this.dt.data.data.splice(t, 1, s), this.dt.update(!0) - } - } - class et { - dt; - settings; - _state; - constructor(t) { - this.dt = t, this.init() - } - init() { - [this.settings, this._state] = ((t = [], e, s) => { - let i = [], - n = !1; - const a = []; - return t.forEach((t => { - (Array.isArray(t.select) ? t.select : [t.select]).forEach((o => { - i[o] ? t.type && (i[o].type = t.type) : i[o] = { - type: t.type || e, - sortable: !0, - searchable: !0 - }; - const r = i[o]; - t.render && (r.render = t.render), t.format ? r.format = t.format : "date" === t.type && (r.format = s), t.cellClass && (r.cellClass = t.cellClass), t.headerClass && (r.headerClass = t.headerClass), t.locale && (r.locale = t.locale), !1 === t.sortable ? r.sortable = !1 : (t.numeric && (r.numeric = t.numeric), t.caseFirst && (r.caseFirst = t.caseFirst)), !1 === t.searchable ? r.searchable = !1 : t.sensitivity && (r.sensitivity = t.sensitivity), (r.searchable || r.sortable) && void 0 !== t.ignorePunctuation && (r.ignorePunctuation = t.ignorePunctuation), t.searchMethod && (r.searchMethod = t.searchMethod), t.hidden && (r.hidden = !0), t.filter && (r.filter = t.filter), t.sortSequence && (r.sortSequence = t.sortSequence), t.sort && (t.filter ? a[o] = t.sort : n = { - column: o, - dir: t.sort - }), void 0 !== t.searchItemSeparator && (r.searchItemSeparator = t.searchItemSeparator) - })) - })), i = i.map((t => t || { - type: e, - format: "date" === e ? s : void 0, - sortable: !0, - searchable: !0 - })), [i, { - filters: a, - sort: n, - widths: [] - }] - })(this.dt.options.columns, this.dt.options.type, this.dt.options.format) - } - get(t) { - return t < 0 || t >= this.size() ? null : { - ...this.settings[t] - } - } - size() { - return this.settings.length - } - swap(t) { - if (2 === t.length) { - const e = this.dt.data.headings.map(((t, e) => e)), - s = t[0], - i = t[1], - n = e[i]; - return e[i] = e[s], e[s] = n, this.order(e) - } - } - order(t) { - this.dt.data.headings = t.map((t => this.dt.data.headings[t])), this.dt.data.data.forEach((e => e.cells = t.map((t => e.cells[t])))), this.settings = t.map((t => this.settings[t])), this.dt.update() - } - hide(t) { - Array.isArray(t) || (t = [t]), t.length && (t.forEach((t => { - this.settings[t] || (this.settings[t] = { - type: "string" - }); - this.settings[t].hidden = !0 - })), this.dt.update()) - } - show(t) { - Array.isArray(t) || (t = [t]), t.length && (t.forEach((t => { - this.settings[t] || (this.settings[t] = { - type: "string", - sortable: !0 - }); - delete this.settings[t].hidden - })), this.dt.update()) - } - visible(t) { - return void 0 === t && (t = [...Array(this.dt.data.headings.length).keys()]), Array.isArray(t) ? t.map((t => !this.settings[t]?.hidden)) : !this.settings[t]?.hidden - } - add(t) { - const e = this.dt.data.headings.length; - if (this.dt.data.headings = this.dt.data.headings.concat([G(t.heading)]), this.dt.data.data.forEach(((e, s) => { - e.cells = e.cells.concat([Z(t.data[s], t)]) - })), this.settings[e] = { - type: t.type || "string", - sortable: !0, - searchable: !0 - }, t.type || t.format || t.sortable || t.render || t.filter) { - const s = this.settings[e]; - t.render && (s.render = t.render), t.format && (s.format = t.format), t.cellClass && (s.cellClass = t.cellClass), t.headerClass && (s.headerClass = t.headerClass), t.locale && (s.locale = t.locale), !1 === t.sortable ? s.sortable = !1 : (t.numeric && (s.numeric = t.numeric), t.caseFirst && (s.caseFirst = t.caseFirst)), !1 === t.searchable ? s.searchable = !1 : t.sensitivity && (s.sensitivity = t.sensitivity), (s.searchable || s.sortable) && t.ignorePunctuation && (s.ignorePunctuation = t.ignorePunctuation), t.hidden && (s.hidden = !0), t.filter && (s.filter = t.filter), t.sortSequence && (s.sortSequence = t.sortSequence) - } - this.dt.update(!0) - } - remove(t) { - Array.isArray(t) || (t = [t]), this.dt.data.headings = this.dt.data.headings.filter(((e, s) => !t.includes(s))), this.dt.data.data.forEach((e => e.cells = e.cells.filter(((e, s) => !t.includes(s))))), this.dt.update(!0) - } - filter(t, e = !1) { - if (!this.settings[t]?.filter?.length) return; - const s = this._state.filters[t]; - let i; - if (s) { - let e = !1; - i = this.settings[t].filter.find((t => !!e || (t === s && (e = !0), !1))) - } else { - const e = this.settings[t].filter; - i = e ? e[0] : void 0 - } - i ? this._state.filters[t] = i : s && (this._state.filters[t] = void 0), this.dt._currentPage = 1, this.dt.update(), e || this.dt.emit("datatable.filter", t, i) - } - sort(t, e = void 0, s = !1) { - const i = this.settings[t]; - if (s || this.dt.emit("datatable.sorting", t, e), !e) { - const s = !(!this._state.sort || this._state.sort.column !== t) && this._state.sort?.dir, - n = i?.sortSequence || ["asc", "desc"]; - if (s) { - const t = n.indexOf(s); - e = -1 === t ? n[0] || "asc" : t === n.length - 1 ? n[0] : n[t + 1] - } else e = n.length ? n[0] : "asc" - } - const a = !!["string", "html"].includes(i.type) && new Intl.Collator(i.locale || this.dt.options.locale, { - usage: "sort", - numeric: i.numeric || this.dt.options.numeric, - caseFirst: i.caseFirst || this.dt.options.caseFirst, - ignorePunctuation: i.ignorePunctuation || this.dt.options.ignorePunctuation - }); - this.dt.data.data.sort(((s, i) => { - const o = s.cells[t], - r = i.cells[t]; - let l = o.order ?? n(o), - d = r.order ?? n(r); - if ("desc" === e) { - const t = l; - l = d, d = t - } - return a && "number" != typeof l && "number" != typeof d ? a.compare(String(l), String(d)) : l < d ? -1 : l > d ? 1 : 0 - })), this._state.sort = { - column: t, - dir: e - }, this.dt._searchQueries.length ? (this.dt.multiSearch(this.dt._searchQueries), this.dt.emit("datatable.sort", t, e)) : s || (this.dt._currentPage = 1, this.dt.update(), this.dt.emit("datatable.sort", t, e)) - } - _measureWidths() { - const t = this.dt.data.headings.filter(((t, e) => !this.settings[e]?.hidden)); - if ((this.dt.options.scrollY.length || this.dt.options.fixedColumns) && t?.length) { - this._state.widths = []; - const t = { - noPaging: !0 - }; - if (this.dt.options.header || this.dt.options.footer) { - this.dt.options.scrollY.length && (t.unhideHeader = !0), this.dt.headerDOM && this.dt.headerDOM.parentElement.removeChild(this.dt.headerDOM), t.noColumnWidths = !0, this.dt._renderTable(t); - const e = Array.from(this.dt.dom.querySelector("thead, tfoot")?.firstElementChild?.querySelectorAll("th") || []); - let s = 0; - const i = this.dt.data.headings.map(((t, i) => { - if (this.settings[i]?.hidden) return 0; - const n = e[s].offsetWidth; - return s += 1, n - })), - n = i.reduce(((t, e) => t + e), 0); - this._state.widths = i.map((t => t / n * 100)) - } else { - t.renderHeader = !0, this.dt._renderTable(t); - const e = Array.from(this.dt.dom.querySelector("thead, tfoot")?.firstElementChild?.querySelectorAll("th") || []); - let s = 0; - const i = this.dt.data.headings.map(((t, i) => { - if (this.settings[i]?.hidden) return 0; - const n = e[s].offsetWidth; - return s += 1, n - })), - n = i.reduce(((t, e) => t + e), 0); - this._state.widths = i.map((t => t / n * 100)) - } - this.dt._renderTable() - } - } - } - const st = { - sortable: !0, - locale: "en", - numeric: !0, - caseFirst: "false", - searchable: !0, - sensitivity: "base", - ignorePunctuation: !0, - destroyable: !0, - searchItemSeparator: "", - searchQuerySeparator: " ", - searchAnd: !1, - searchMethod: !1, - data: {}, - type: "html", - format: "YYYY-MM-DD", - columns: [], - paging: !0, - perPage: 10, - perPageSelect: [5, 10, 15, 20, 25], - nextPrev: !0, - firstLast: !1, - prevText: "‹", - nextText: "›", - firstText: "«", - lastText: "»", - ellipsisText: "…", - truncatePager: !0, - pagerDelta: 2, - scrollY: "", - fixedColumns: !0, - fixedHeight: !1, - footer: !1, - header: !0, - hiddenHeader: !1, - caption: void 0, - rowNavigation: !1, - rowSelectionKeys: ["Enter", " "], - tabIndex: !1, - pagerRender: !1, - rowRender: !1, - tableRender: !1, - diffDomOptions: { - valueDiffing: !1 - }, - labels: { - placeholder: "Search...", - searchTitle: "Search within table", - perPage: "entries per page", - pageTitle: "Page {page}", - noRows: "No entries found", - noResults: "No results match your search query", - info: "Showing {start} to {end} of {rows} entries" - }, - template: (t, e) => `
        \n ${t.paging&&t.perPageSelect?`
        \n \n
        `:""}\n ${t.searchable?`
        \n \n
        `:""}\n
        \n
        \n
        \n ${t.paging?`
        `:""}\n \n
        `, - classes: { - active: "datatable-active", - ascending: "datatable-ascending", - bottom: "datatable-bottom", - container: "datatable-container", - cursor: "datatable-cursor", - descending: "datatable-descending", - disabled: "datatable-disabled", - dropdown: "datatable-dropdown", - ellipsis: "datatable-ellipsis", - filter: "datatable-filter", - filterActive: "datatable-filter-active", - empty: "datatable-empty", - headercontainer: "datatable-headercontainer", - hidden: "datatable-hidden", - info: "datatable-info", - input: "datatable-input", - loading: "datatable-loading", - pagination: "datatable-pagination", - paginationList: "datatable-pagination-list", - paginationListItem: "datatable-pagination-list-item", - paginationListItemLink: "datatable-pagination-list-item-link", - search: "datatable-search", - selector: "datatable-selector", - sorter: "datatable-sorter", - table: "datatable-table", - top: "datatable-top", - wrapper: "datatable-wrapper" - } - }, - it = (t, e, s, i = {}) => ({ - nodeName: "LI", - attributes: { - class: i.active && !i.hidden ? `${s.classes.paginationListItem} ${s.classes.active}` : i.hidden ? `${s.classes.paginationListItem} ${s.classes.hidden} ${s.classes.disabled}` : s.classes.paginationListItem - }, - childNodes: [{ - nodeName: "BUTTON", - attributes: { - "data-page": String(t), - class: s.classes.paginationListItemLink, - "aria-label": s.labels.pageTitle.replace("{page}", String(t)) - }, - childNodes: [{ - nodeName: "#text", - data: e - }] - }] - }), - nt = (t, e, s, i, n) => { - let a = []; - if (n.firstLast && a.push(it(1, n.firstText, n)), n.nextPrev) { - const e = t ? 1 : s - 1; - a.push(it(e, n.prevText, n, { - hidden: t - })) - } - let o = [...Array(i).keys()].map((t => it(t + 1, String(t + 1), n, { - active: t === s - 1 - }))); - if (n.truncatePager && (o = ((t, e, s, i) => { - const n = i.pagerDelta, - a = i.classes, - o = i.ellipsisText, - r = 2 * n; - let l = e - n, - d = e + n; - e < 4 - n + r ? d = 3 + r : e > s - (3 - n + r) && (l = s - (2 + r)); - const c = []; - for (let e = 1; e <= s; e++) - if (1 == e || e == s || e >= l && e <= d) { - const s = t[e - 1]; - c.push(s) - } let h; - const u = []; - return c.forEach((e => { - const s = parseInt(e.childNodes[0].attributes["data-page"], 10); - if (h) { - const e = parseInt(h.childNodes[0].attributes["data-page"], 10); - if (s - e == 2) u.push(t[e]); - else if (s - e != 1) { - const t = { - nodeName: "LI", - attributes: { - class: `${a.paginationListItem} ${a.ellipsis} ${a.disabled}` - }, - childNodes: [{ - nodeName: "BUTTON", - attributes: { - class: a.paginationListItemLink - }, - childNodes: [{ - nodeName: "#text", - data: o - }] - }] - }; - u.push(t) - } - } - u.push(e), h = e - })), u - })(o, s, i, n)), a = a.concat(o), n.nextPrev) { - const t = e ? i : s + 1; - a.push(it(t, n.nextText, n, { - hidden: e - })) - } - n.firstLast && a.push(it(i, n.lastText, n)); - return { - nodeName: "UL", - attributes: { - class: n.classes.paginationList - }, - childNodes: o.length > 1 ? a : [] - } - }; - const at = { - classes: { - row: "datatable-editor-row", - form: "datatable-editor-form", - item: "datatable-editor-item", - menu: "datatable-editor-menu", - save: "datatable-editor-save", - block: "datatable-editor-block", - cancel: "datatable-editor-cancel", - close: "datatable-editor-close", - inner: "datatable-editor-inner", - input: "datatable-editor-input", - label: "datatable-editor-label", - modal: "datatable-editor-modal", - action: "datatable-editor-action", - header: "datatable-editor-header", - wrapper: "datatable-editor-wrapper", - editable: "datatable-editor-editable", - container: "datatable-editor-container", - separator: "datatable-editor-separator" - }, - labels: { - closeX: "x", - editCell: "Edit Cell", - editRow: "Edit Row", - removeRow: "Remove Row", - reallyRemove: "Are you sure?", - reallyCancel: "Do you really want to cancel?", - save: "Save", - cancel: "Cancel" - }, - cancelModal: t => confirm(t.options.labels.reallyCancel), - inline: !0, - hiddenColumns: !1, - contextMenu: !0, - clickEvent: "dblclick", - excludeColumns: [], - menuItems: [{ - text: t => t.options.labels.editCell, - action: (t, e) => { - if (!(t.event.target instanceof Element)) return; - const s = t.event.target.closest("td"); - return t.editCell(s) - } - }, { - text: t => t.options.labels.editRow, - action: (t, e) => { - if (!(t.event.target instanceof Element)) return; - const s = t.event.target.closest("tr"); - return t.editRow(s) - } - }, { - separator: !0 - }, { - text: t => t.options.labels.removeRow, - action: (t, e) => { - if (t.event.target instanceof Element && confirm(t.options.labels.reallyRemove)) { - const e = t.event.target.closest("tr"); - t.removeRow(e) - } - } - }] - }; - class ot { - menuOpen; - containerDOM; - data; - disabled; - dt; - editing; - editingCell; - editingRow; - event; - events; - initialized; - limits; - menuDOM; - modalDOM; - options; - originalRowRender; - rect; - wrapperDOM; - constructor(t, e = {}) { - this.dt = t, this.options = { - ...at, - ...e - } - } - init() { - this.initialized || (this.options.classes.editable?.split(" ").forEach((t => this.dt.wrapperDOM.classList.add(t))), this.options.inline && (this.originalRowRender = this.dt.options.rowRender, this.dt.options.rowRender = (t, e, s) => { - let i = this.rowRender(t, e, s); - return this.originalRowRender && (i = this.originalRowRender(t, i, s)), i - }), this.options.contextMenu && (this.containerDOM = s("div", { - id: this.options.classes.container - }), this.wrapperDOM = s("div", { - class: this.options.classes.wrapper - }), this.menuDOM = s("ul", { - class: this.options.classes.menu - }), this.options.menuItems && this.options.menuItems.length && this.options.menuItems.forEach((t => { - const e = s("li", { - class: t.separator ? this.options.classes.separator : this.options.classes.item - }); - if (!t.separator) { - const i = s("a", { - class: this.options.classes.action, - href: t.url || "#", - html: "function" == typeof t.text ? t.text(this) : t.text - }); - e.appendChild(i), t.action && "function" == typeof t.action && i.addEventListener("click", (e => { - e.preventDefault(), t.action(this, e) - })) - } - this.menuDOM.appendChild(e) - })), this.wrapperDOM.appendChild(this.menuDOM), this.containerDOM.appendChild(this.wrapperDOM), this.updateMenu()), this.data = {}, this.menuOpen = !1, this.editing = !1, this.editingRow = !1, this.editingCell = !1, this.bindEvents(), setTimeout((() => { - this.initialized = !0, this.dt.emit("editable.init") - }), 10)) - } - bindEvents() { - this.events = { - keydown: this.keydown.bind(this), - click: this.click.bind(this) - }, this.dt.dom.addEventListener(this.options.clickEvent, this.events.click), document.addEventListener("keydown", this.events.keydown), this.options.contextMenu && (this.events.context = this.context.bind(this), this.events.updateMenu = this.updateMenu.bind(this), this.events.dismissMenu = this.dismissMenu.bind(this), this.events.reset = h((() => this.events.updateMenu()), 50), this.dt.dom.addEventListener("contextmenu", this.events.context), document.addEventListener("click", this.events.dismissMenu), window.addEventListener("resize", this.events.reset), window.addEventListener("scroll", this.events.reset)) - } - context(t) { - const e = t.target; - if (!(e instanceof Element)) return; - this.event = t; - const s = e.closest("tbody td"); - if (!this.disabled && s) { - t.preventDefault(); - let e = t.pageX, - s = t.pageY; - e > this.limits.x && (e -= this.rect.width), s > this.limits.y && (s -= this.rect.height), this.wrapperDOM.style.top = `${s}px`, this.wrapperDOM.style.left = `${e}px`, this.openMenu(), this.updateMenu() - } - } - click(t) { - const e = t.target; - if (e instanceof Element) - if (this.editing && this.data && this.editingCell) { - const t = l(this.options.classes.input), - e = this.modalDOM ? this.modalDOM.querySelector(`input${t}[type=text]`) : this.dt.wrapperDOM.querySelector(`input${t}[type=text]`); - this.saveCell(e.value) - } else if (!this.editing) { - const s = e.closest("tbody td"); - s && (this.editCell(s), t.preventDefault()) - } - } - keydown(t) { - const e = l(this.options.classes.input); - if (this.modalDOM) { - if ("Escape" === t.key) this.options.cancelModal(this) && this.closeModal(); - else if ("Enter" === t.key) - if (this.editingCell) { - const t = this.modalDOM.querySelector(`input${e}[type=text]`); - this.saveCell(t.value) - } else { - const t = Array.from(this.modalDOM.querySelectorAll(`input${e}[type=text]`)).map((t => t.value.trim())); - this.saveRow(t, this.data.row) - } - } else if (this.editing && this.data) - if ("Enter" === t.key) { - if (this.editingCell) { - const t = this.dt.wrapperDOM.querySelector(`input${e}[type=text]`); - this.saveCell(t.value) - } else if (this.editingRow) { - const t = Array.from(this.dt.wrapperDOM.querySelectorAll(`input${e}[type=text]`)).map((t => t.value.trim())); - this.saveRow(t, this.data.row) - } - } else "Escape" === t.key && (this.editingCell ? this.saveCell(this.data.content) : this.editingRow && this.saveRow(null, this.data.row)) - } - editCell(t) { - const e = o(t.cellIndex, this.dt.columns.settings); - if (this.options.excludeColumns.includes(e)) return void this.closeMenu(); - const s = parseInt(t.parentElement.dataset.index, 10), - i = this.dt.data.data[s].cells[e]; - this.data = { - cell: i, - rowIndex: s, - columnIndex: e, - content: n(i) - }, this.editing = !0, this.editingCell = !0, this.options.inline ? this.dt.update() : this.editCellModal(), this.closeMenu() - } - editCellModal() { - const t = this.data.cell, - e = this.data.columnIndex, - i = this.dt.data.headings[e].text || String(this.dt.data.headings[e].data), - o = [`
        `, `
        `, `

        ${this.options.labels.editCell}

        `, ``, "
        ", `
        `, `
        `, `
        `, ``, ``, "
        ", `
        `, ``, ``, "
        ", "
        ", "
        ", "
        "].join(""), - r = s("div", { - class: this.options.classes.modal, - html: o - }); - this.modalDOM = r, this.openModal(); - const d = l(this.options.classes.input), - c = r.querySelector(`input${d}[type=text]`); - c.focus(), c.selectionStart = c.selectionEnd = c.value.length, r.addEventListener("click", (t => { - const e = t.target; - e instanceof Element && (e.hasAttribute("data-editor-cancel") ? (t.preventDefault(), this.options.cancelModal(this) && this.closeModal()) : e.hasAttribute("data-editor-save") && (t.preventDefault(), this.saveCell(c.value))) - })) - } - saveCell(t) { - const e = this.data.content, - s = this.dt.columns.settings[this.data.columnIndex].type || this.dt.options.type, - i = t.trim(); - let n; - if ("number" === s) n = { - data: parseFloat(i) - }; - else if ("boolean" === s) n = ["", "false", "0"].includes(i) ? { - data: !1, - text: "false", - order: 0 - } : { - data: !0, - text: "true", - order: 1 - }; - else if ("html" === s) n = { - data: [{ - nodeName: "#text", - data: t - }], - text: t, - order: t - }; - else if ("string" === s) n = { - data: t - }; - else if ("date" === s) { - const e = this.dt.columns.settings[this.data.columnIndex].format || this.dt.options.format; - n = { - data: t, - order: X(String(t), e) - } - } else n = { - data: t - }; - this.dt.data.data[this.data.rowIndex].cells[this.data.columnIndex] = n, this.closeModal(); - const a = this.data.rowIndex, - o = this.data.columnIndex; - this.data = {}, this.dt.update(!0), this.editing = !1, this.editingCell = !1, this.dt.emit("editable.save.cell", t, e, a, o) - } - editRow(t) { - if (!t || "TR" !== t.nodeName || this.editing) return; - const e = parseInt(t.dataset.index, 10), - s = this.dt.data.data[e]; - this.data = { - row: s.cells, - rowIndex: e - }, this.editing = !0, this.editingRow = !0, this.options.inline ? this.dt.update() : this.editRowModal(), this.closeMenu() - } - editRowModal() { - const t = this.data.row, - e = [`
        `, `
        `, `

        ${this.options.labels.editRow}

        `, ``, "
        ", `
        `, `
        `, `
        `, ``, ``, "
        ", "
        ", "
        ", "
        "].join(""), - i = s("div", { - class: this.options.classes.modal, - html: e - }), - o = i.firstElementChild; - if (!o) return; - const r = o.lastElementChild?.firstElementChild; - if (!r) return; - t.forEach(((t, e) => { - const i = this.dt.columns.settings[e]; - if ((!i.hidden || i.hidden && this.options.hiddenColumns) && !this.options.excludeColumns.includes(e)) { - const i = this.dt.data.headings[e].text || String(this.dt.data.headings[e].data); - r.insertBefore(s("div", { - class: this.options.classes.row, - html: [`
        `, ``, ``, "
        "].join("") - }), r.lastElementChild) - } - })), this.modalDOM = i, this.openModal(); - const d = l(this.options.classes.input), - c = Array.from(r.querySelectorAll(`input${d}[type=text]`)); - i.addEventListener("click", (t => { - const e = t.target; - if (e instanceof Element) - if (e.hasAttribute("data-editor-cancel")) this.options.cancelModal(this) && this.closeModal(); - else if (e.hasAttribute("data-editor-save")) { - const t = c.map((t => t.value.trim())); - this.saveRow(t, this.data.row) - } - })) - } - saveRow(t, e) { - const s = e.map((t => n(t))), - i = this.dt.data.data[this.data.rowIndex]; - if (t) { - let s = 0; - i.cells = e.map(((e, i) => { - if (this.options.excludeColumns.includes(i) || this.dt.columns.settings[i].hidden) return e; - const n = this.dt.columns.settings[i].type || this.dt.options.type, - a = t[s++]; - let o; - if ("number" === n) o = { - data: parseFloat(a) - }; - else if ("boolean" === n) o = ["", "false", "0"].includes(a) ? { - data: !1, - text: "false", - order: 0 - } : { - data: !0, - text: "true", - order: 1 - }; - else if ("html" === n) o = { - data: [{ - nodeName: "#text", - data: a - }], - text: a, - order: a - }; - else if ("string" === n) o = { - data: a - }; - else if ("date" === n) { - const t = this.dt.columns.settings[i].format || this.dt.options.format; - o = { - data: a, - order: X(String(a), t) - } - } else o = { - data: a - }; - return o - })) - } - const a = i.cells.map((t => n(t))); - this.data = {}, this.dt.update(!0), this.closeModal(), this.editing = !1, this.dt.emit("editable.save.row", a, s, e) - } - openModal() { - this.modalDOM && document.body.appendChild(this.modalDOM) - } - closeModal() { - this.editing && this.modalDOM && (document.body.removeChild(this.modalDOM), this.modalDOM = this.editing = this.editingRow = this.editingCell = !1) - } - removeRow(t) { - if (!t || "TR" !== t.nodeName || this.editing) return; - const e = parseInt(t.dataset.index, 10); - this.dt.rows.remove(e), this.closeMenu() - } - updateMenu() { - const t = window.scrollX || window.pageXOffset, - e = window.scrollY || window.pageYOffset; - this.rect = this.wrapperDOM.getBoundingClientRect(), this.limits = { - x: window.innerWidth + t - this.rect.width, - y: window.innerHeight + e - this.rect.height - } - } - dismissMenu(t) { - const e = t.target; - if (!(e instanceof Element) || this.wrapperDOM.contains(e)) return; - let s = !0; - if (this.editing) { - const t = l(this.options.classes.input); - s = !e.matches(`input${t}[type=text]`) - } - s && this.closeMenu() - } - openMenu() { - if (this.editing && this.data && this.editingCell) { - const t = l(this.options.classes.input), - e = this.modalDOM ? this.modalDOM.querySelector(`input${t}[type=text]`) : this.dt.wrapperDOM.querySelector(`input${t}[type=text]`); - this.saveCell(e.value) - } - document.body.appendChild(this.containerDOM), this.menuOpen = !0, this.dt.emit("editable.context.open") - } - closeMenu() { - this.menuOpen && (this.menuOpen = !1, document.body.removeChild(this.containerDOM), this.dt.emit("editable.context.close")) - } - destroy() { - this.dt.dom.removeEventListener(this.options.clickEvent, this.events.click), this.dt.dom.removeEventListener("contextmenu", this.events.context), document.removeEventListener("click", this.events.dismissMenu), document.removeEventListener("keydown", this.events.keydown), window.removeEventListener("resize", this.events.reset), window.removeEventListener("scroll", this.events.reset), document.body.contains(this.containerDOM) && document.body.removeChild(this.containerDOM), this.options.inline && (this.dt.options.rowRender = this.originalRowRender), this.initialized = !1 - } - rowRender(t, e, s) { - if (!this.data || this.data.rowIndex !== s) return e; - if (this.editingCell) { - e.childNodes[function(t, e) { - let s = t, - i = 0; - for (; i < t;) e[i].hidden && (s -= 1), i++; - return s - }(this.data.columnIndex, this.dt.columns.settings)].childNodes = [{ - nodeName: "INPUT", - attributes: { - type: "text", - value: this.data.content, - class: this.options.classes.input - } - }] - } else e.childNodes.forEach(((s, i) => { - const n = o(i, this.dt.columns.settings), - r = t[n]; - if (!this.options.excludeColumns.includes(n)) { - e.childNodes[i].childNodes = [{ - nodeName: "INPUT", - attributes: { - type: "text", - value: a(r.text || String(r.data) || ""), - class: this.options.classes.input - } - }] - } - })); - return e - } - } - const rt = { - classes: { - button: "datatable-column-filter-button", - menu: "datatable-column-filter-menu", - container: "datatable-column-filter-container", - wrapper: "datatable-column-filter-wrapper" - }, - labels: { - button: "Filter columns within the table" - }, - hiddenColumns: [] - }; - class lt { - addedButtonDOM; - menuOpen; - buttonDOM; - dt; - events; - initialized; - options; - menuDOM; - containerDOM; - wrapperDOM; - limits; - rect; - event; - constructor(t, e = {}) { - this.dt = t, this.options = { - ...rt, - ...e - } - } - init() { - if (this.initialized) return; - const t = l(this.options.classes.button); - let e = this.dt.wrapperDOM.querySelector(t); - if (!e) { - e = s("button", { - class: this.options.classes.button, - html: "▦" - }); - const t = l(this.dt.options.classes.search), - i = this.dt.wrapperDOM.querySelector(t); - i ? i.appendChild(e) : this.dt.wrapperDOM.appendChild(e), this.addedButtonDOM = !0 - } - this.buttonDOM = e, this.containerDOM = s("div", { - id: this.options.classes.container - }), this.wrapperDOM = s("div", { - class: this.options.classes.wrapper - }), this.menuDOM = s("ul", { - class: this.options.classes.menu, - html: this.dt.data.headings.map(((t, e) => { - const s = this.dt.columns.settings[e]; - return this.options.hiddenColumns.includes(e) ? "" : `
      • \n \n \n
      • ` - })).join("") - }), this.wrapperDOM.appendChild(this.menuDOM), this.containerDOM.appendChild(this.wrapperDOM), this._measureSpace(), this._bind(), this.initialized = !0 - } - dismiss() { - this.addedButtonDOM && this.buttonDOM.parentElement && this.buttonDOM.parentElement.removeChild(this.buttonDOM), document.removeEventListener("click", this.events.click) - } - _bind() { - this.events = { - click: this._click.bind(this) - }, document.addEventListener("click", this.events.click) - } - _openMenu() { - document.body.appendChild(this.containerDOM), this._measureSpace(), this.menuOpen = !0, this.dt.emit("columnFilter.menu.open") - } - _closeMenu() { - this.menuOpen && (this.menuOpen = !1, document.body.removeChild(this.containerDOM), this.dt.emit("columnFilter.menu.close")) - } - _measureSpace() { - const t = window.scrollX || window.pageXOffset, - e = window.scrollY || window.pageYOffset; - this.rect = this.wrapperDOM.getBoundingClientRect(), this.limits = { - x: window.innerWidth + t - this.rect.width, - y: window.innerHeight + e - this.rect.height - } - } - _click(t) { - const e = t.target; - if (e instanceof Element) - if (this.event = t, this.buttonDOM.contains(e)) { - if (t.preventDefault(), this.menuOpen) return void this._closeMenu(); - this._openMenu(); - let e = t.pageX, - s = t.pageY; - e > this.limits.x && (e -= this.rect.width), s > this.limits.y && (s -= this.rect.height), this.wrapperDOM.style.top = `${s}px`, this.wrapperDOM.style.left = `${e}px` - } else if (this.menuDOM.contains(e)) { - const t = l(this.options.classes.menu), - s = e.closest(`${t} > li`); - if (!s) return; - const i = s.querySelector("input[type=checkbox]"); - i.contains(e) || (i.checked = !i.checked); - const n = Number(s.dataset.column); - i.checked ? this.dt.columns.show([n]) : this.dt.columns.hide([n]) - } else this.menuOpen && this._closeMenu() - } - } - exports.DataTable = class { - columns; - containerDOM; - _currentPage; - data; - _dd; - dom; - _events; - hasHeadings; - hasRows; - headerDOM; - _initialHTML; - initialized; - _label; - lastPage; - _listeners; - onFirstPage; - onLastPage; - options; - _pagerDOMs; - _virtualPagerDOM; - pages; - _rect; - rows; - _searchData; - _searchQueries; - _tableAttributes; - _tableFooters; - _tableCaptions; - totalPages; - _virtualDOM; - _virtualHeaderDOM; - wrapperDOM; - constructor(t, e = {}) { - const s = "string" == typeof t ? document.querySelector(t) : t; - s instanceof HTMLTableElement ? this.dom = s : (this.dom = document.createElement("table"), s.appendChild(this.dom)); - const i = { - ...st.diffDomOptions, - ...e.diffDomOptions - }, - n = { - ...st.labels, - ...e.labels - }, - a = { - ...st.classes, - ...e.classes - }; - this.options = { - ...st, - ...e, - diffDomOptions: i, - labels: n, - classes: a - }, this._initialHTML = this.options.destroyable ? s.outerHTML : "", this.options.tabIndex ? this.dom.tabIndex = this.options.tabIndex : this.options.rowNavigation && -1 === this.dom.tabIndex && (this.dom.tabIndex = 0), this._listeners = { - onResize: () => this._onResize() - }, this._dd = new F(this.options.diffDomOptions || {}), this.initialized = !1, this._events = {}, this._currentPage = 0, this.onFirstPage = !0, this.hasHeadings = !1, this.hasRows = !1, this._searchQueries = [], this.init() - } - init() { - if (this.initialized || d(this.dom, this.options.classes.table)) return !1; - this._virtualDOM = T(this.dom, this.options.diffDomOptions || {}), this._tableAttributes = { - ...this._virtualDOM.attributes - }, this._tableFooters = this._virtualDOM.childNodes?.filter((t => "TFOOT" === t.nodeName)) ?? [], this._tableCaptions = this._virtualDOM.childNodes?.filter((t => "CAPTION" === t.nodeName)) ?? [], void 0 !== this.options.caption && this._tableCaptions.push({ - nodeName: "CAPTION", - childNodes: [{ - nodeName: "#text", - data: this.options.caption - }] - }), this.rows = new tt(this), this.columns = new et(this), this.data = K(this.options.data, this.dom, this.columns.settings, this.options.type, this.options.format), this._render(), setTimeout((() => { - this.emit("datatable.init"), this.initialized = !0 - }), 10) - } - _render() { - this.wrapperDOM = s("div", { - class: `${this.options.classes.wrapper} ${this.options.classes.loading}` - }), this.wrapperDOM.innerHTML = this.options.template(this.options, this.dom); - const t = l(this.options.classes.selector), - e = this.wrapperDOM.querySelector(`select${t}`); - e && this.options.paging && this.options.perPageSelect ? this.options.perPageSelect.forEach((t => { - const [s, i] = Array.isArray(t) ? [t[0], t[1]] : [String(t), t], n = i === this.options.perPage, a = new Option(s, String(i), n, n); - e.appendChild(a) - })) : e && e.parentElement.removeChild(e); - const i = l(this.options.classes.container); - this.containerDOM = this.wrapperDOM.querySelector(i), this._pagerDOMs = []; - const n = l(this.options.classes.pagination); - Array.from(this.wrapperDOM.querySelectorAll(n)).forEach((t => { - t instanceof HTMLElement && (t.innerHTML = `
          `, this._pagerDOMs.push(t.firstElementChild)) - })), this._virtualPagerDOM = { - nodeName: "UL", - attributes: { - class: this.options.classes.paginationList - } - }; - const a = l(this.options.classes.info); - this._label = this.wrapperDOM.querySelector(a), this.dom.parentElement.replaceChild(this.wrapperDOM, this.dom), this.containerDOM.appendChild(this.dom), this._rect = this.dom.getBoundingClientRect(), this._fixHeight(), this.options.header || this.wrapperDOM.classList.add("no-header"), this.options.footer || this.wrapperDOM.classList.add("no-footer"), this.options.sortable && this.wrapperDOM.classList.add("sortable"), this.options.searchable && this.wrapperDOM.classList.add("searchable"), this.options.fixedHeight && this.wrapperDOM.classList.add("fixed-height"), this.options.fixedColumns && this.wrapperDOM.classList.add("fixed-columns"), this._bindEvents(), this.columns._state.sort && this.columns.sort(this.columns._state.sort.column, this.columns._state.sort.dir, !0), this.update(!0) - } - _renderTable(t = {}) { - let e; - e = (this.options.paging || this._searchQueries.length || this.columns._state.filters.length) && this._currentPage && this.pages.length && !t.noPaging ? this.pages[this._currentPage - 1] : this.data.data.map(((t, e) => ({ - row: t, - index: e - }))); - let s = B(this._tableAttributes, this.data.headings, e, this.columns.settings, this.columns._state, this.rows.cursor, this.options, t, this._tableFooters, this._tableCaptions); - if (this.options.tableRender) { - const t = this.options.tableRender(this.data, s, "main"); - t && (s = t) - } - const i = this._dd.diff(this._virtualDOM, s); - this._dd.apply(this.dom, i), this._virtualDOM = s - } - _renderPage(t = !1) { - this.hasRows && this.totalPages ? (this._currentPage > this.totalPages && (this._currentPage = 1), this._renderTable(), this.onFirstPage = 1 === this._currentPage, this.onLastPage = this._currentPage === this.lastPage) : this.setMessage(this.options.labels.noRows); - let e, s = 0, - i = 0, - n = 0; - if (this.totalPages && (s = this._currentPage - 1, i = s * this.options.perPage, n = i + this.pages[s].length, i += 1, e = this._searchQueries.length ? this._searchData.length : this.data.data.length), this._label && this.options.labels.info.length) { - const t = this.options.labels.info.replace("{start}", String(i)).replace("{end}", String(n)).replace("{page}", String(this._currentPage)).replace("{pages}", String(this.totalPages)).replace("{rows}", String(e)); - this._label.innerHTML = e ? t : "" - } - if (1 == this._currentPage && this._fixHeight(), this.options.rowNavigation && this._currentPage && (!this.rows.cursor || !this.pages[this._currentPage - 1].find((t => t.index === this.rows.cursor)))) { - const e = this.pages[this._currentPage - 1]; - e.length && (t ? this.rows.setCursor(e[e.length - 1].index) : this.rows.setCursor(e[0].index)) - } - } - _renderPagers() { - if (!this.options.paging) return; - let t = nt(this.onFirstPage, this.onLastPage, this._currentPage, this.totalPages, this.options); - if (this.options.pagerRender) { - const e = this.options.pagerRender([this.onFirstPage, this.onLastPage, this._currentPage, this.totalPages], t); - e && (t = e) - } - const e = this._dd.diff(this._virtualPagerDOM, t); - this._pagerDOMs.forEach((t => { - this._dd.apply(t, e) - })), this._virtualPagerDOM = t - } - _renderSeparateHeader() { - const t = this.dom.parentElement; - this.headerDOM || (this.headerDOM = document.createElement("div"), this._virtualHeaderDOM = { - nodeName: "DIV" - }), t.parentElement.insertBefore(this.headerDOM, t); - let e = { - nodeName: "TABLE", - attributes: this._tableAttributes, - childNodes: [{ - nodeName: "THEAD", - childNodes: [q(this.data.headings, this.columns.settings, this.columns._state, this.options, { - unhideHeader: !0 - })] - }] - }; - if (e.attributes.class = c(e.attributes.class, this.options.classes.table), this.options.tableRender) { - const t = this.options.tableRender(this.data, e, "header"); - t && (e = t) - } - const s = { - nodeName: "DIV", - attributes: { - class: this.options.classes.headercontainer - }, - childNodes: [e] - }, - i = this._dd.diff(this._virtualHeaderDOM, s); - this._dd.apply(this.headerDOM, i), this._virtualHeaderDOM = s; - const n = this.headerDOM.firstElementChild.clientWidth - this.dom.clientWidth; - if (n) { - const t = structuredClone(this._virtualHeaderDOM); - t.attributes.style = `padding-right: ${n}px;`; - const e = this._dd.diff(this._virtualHeaderDOM, t); - this._dd.apply(this.headerDOM, e), this._virtualHeaderDOM = t - } - t.scrollHeight > t.clientHeight && (t.style.overflowY = "scroll") - } - _bindEvents() { - if (this.options.perPageSelect) { - const t = l(this.options.classes.selector), - e = this.wrapperDOM.querySelector(t); - e && e instanceof HTMLSelectElement && e.addEventListener("change", (() => { - this.emit("datatable.perpage:before", this.options.perPage), this.options.perPage = parseInt(e.value, 10), this.update(), this._fixHeight(), this.emit("datatable.perpage", this.options.perPage) - }), !1) - } - this.options.searchable && this.wrapperDOM.addEventListener("input", (t => { - const e = l(this.options.classes.input), - s = t.target; - if (!(s instanceof HTMLInputElement && s.matches(e))) return; - t.preventDefault(); - const i = []; - if (Array.from(this.wrapperDOM.querySelectorAll(e)).filter((t => t.value.length)).forEach((t => { - const e = t.dataset.and || this.options.searchAnd, - s = t.dataset.querySeparator || this.options.searchQuerySeparator ? t.value.split(this.options.searchQuerySeparator) : [t.value]; - e ? s.forEach((e => { - t.dataset.columns ? i.push({ - terms: [e], - columns: JSON.parse(t.dataset.columns) - }) : i.push({ - terms: [e], - columns: void 0 - }) - })) : t.dataset.columns ? i.push({ - terms: s, - columns: JSON.parse(t.dataset.columns) - }) : i.push({ - terms: s, - columns: void 0 - }) - })), 1 === i.length && 1 === i[0].terms.length) { - const t = i[0]; - this.search(t.terms[0], t.columns) - } else this.multiSearch(i) - })), this.wrapperDOM.addEventListener("click", (t => { - const e = t.target.closest("a, button"); - if (e) - if (e.hasAttribute("data-page")) this.page(parseInt(e.getAttribute("data-page"), 10)), t.preventDefault(); - else if (d(e, this.options.classes.sorter)) { - const s = Array.from(e.parentElement.parentElement.children).indexOf(e.parentElement), - i = o(s, this.columns.settings); - this.columns.sort(i), t.preventDefault() - } else if (d(e, this.options.classes.filter)) { - const s = Array.from(e.parentElement.parentElement.children).indexOf(e.parentElement), - i = o(s, this.columns.settings); - this.columns.filter(i), t.preventDefault() - } - }), !1), this.options.rowNavigation && this.dom.addEventListener("keydown", (t => { - if ("ArrowUp" === t.key) { - let e; - t.preventDefault(), t.stopPropagation(), this.pages[this._currentPage - 1].find((t => t.index === this.rows.cursor || (e = t, !1))), e ? this.rows.setCursor(e.index) : this.onFirstPage || this.page(this._currentPage - 1, !0) - } else if ("ArrowDown" === t.key) { - let e; - t.preventDefault(), t.stopPropagation(); - const s = this.pages[this._currentPage - 1].find((t => !!e || (t.index === this.rows.cursor && (e = !0), !1))); - s ? this.rows.setCursor(s.index) : this.onLastPage || this.page(this._currentPage + 1) - } else this.options.rowSelectionKeys.includes(t.key) && this.emit("datatable.selectrow", this.rows.cursor, t, !0) - })), this.dom.addEventListener("mousedown", (t => { - const e = t.target; - if (!(e instanceof Element)) return; - const s = Array.from(this.dom.querySelectorAll("tbody > tr")).find((t => t.contains(e))); - s && s instanceof HTMLElement && this.emit("datatable.selectrow", parseInt(s.dataset.index, 10), t, this.dom.matches(":focus")) - })), window.addEventListener("resize", this._listeners.onResize) - } - _onResize = h((() => { - this._rect = this.containerDOM.getBoundingClientRect(), this._rect.width && this.update(!0) - }), 250); - destroy() { - if (this.options.destroyable) { - if (this.wrapperDOM) { - const t = this.wrapperDOM.parentElement; - if (t) { - const e = s("div"); - e.innerHTML = this._initialHTML; - const i = e.firstElementChild; - t.replaceChild(i, this.wrapperDOM), this.dom = i - } else this.options.classes.table?.split(" ").forEach((t => this.wrapperDOM.classList.remove(t))) - } - window.removeEventListener("resize", this._listeners.onResize), this.initialized = !1 - } - } - update(t = !1) { - this.emit("datatable.update:before"), t && (this.columns._measureWidths(), this.hasRows = Boolean(this.data.data.length), this.hasHeadings = Boolean(this.data.headings.length)), this.options.classes.empty?.split(" ").forEach((t => this.wrapperDOM.classList.remove(t))), this._paginate(), this._renderPage(), this._renderPagers(), this.options.scrollY.length && this._renderSeparateHeader(), this.emit("datatable.update") - } - _paginate() { - let t = this.data.data.map(((t, e) => ({ - row: t, - index: e - }))); - return this._searchQueries.length && (t = [], this._searchData.forEach((e => t.push({ - index: e, - row: this.data.data[e] - })))), this.columns._state.filters.length && this.columns._state.filters.forEach(((e, s) => { - e && (t = t.filter((t => { - const i = t.row.cells[s]; - return "function" == typeof e ? e(i.data) : n(i) === e - }))) - })), this.options.paging && this.options.perPage > 0 ? this.pages = t.map(((e, s) => s % this.options.perPage == 0 ? t.slice(s, s + this.options.perPage) : null)).filter((t => t)) : this.pages = [t], this.totalPages = this.lastPage = this.pages.length, this._currentPage || (this._currentPage = 1), this.totalPages - } - _fixHeight() { - this.options.fixedHeight && (this.containerDOM.style.height = null, this._rect = this.containerDOM.getBoundingClientRect(), this.containerDOM.style.height = `${this._rect.height}px`) - } - search(t, e = void 0, s = "search") { - if (this.emit("datatable.search:before", t, this._searchData), !t.length) return this._currentPage = 1, this._searchQueries = [], this._searchData = [], this.update(), this.emit("datatable.search", "", []), this.wrapperDOM.classList.remove("search-results"), !1; - this.multiSearch([{ - terms: [t], - columns: e || void 0 - }], s), this.emit("datatable.search", t, this._searchData) - } - multiSearch(t, e = "search") { - if (!this.hasRows) return !1; - this._currentPage = 1, this._searchData = []; - let s = t.map((t => ({ - columns: t.columns, - terms: t.terms.map((t => t.trim())).filter((t => t)), - source: e - }))).filter((t => t.terms.length)); - if (this.emit("datatable.multisearch:before", s, this._searchData), e.length && (s = s.concat(this._searchQueries.filter((t => t.source !== e)))), this._searchQueries = s, !s.length) return this.update(), this.emit("datatable.multisearch", s, this._searchData), this.wrapperDOM.classList.remove("search-results"), !1; - const i = s.map((t => this.columns.settings.map(((e, s) => { - if (e.hidden || !e.searchable || t.columns && !t.columns.includes(s)) return !1; - let i = t.terms; - const n = e.sensitivity || this.options.sensitivity; - ["base", "accent"].includes(n) && (i = i.map((t => t.toLowerCase()))), ["base", "case"].includes(n) && (i = i.map((t => t.normalize("NFD").replace(/\p{Diacritic}/gu, "")))); - return (e.ignorePunctuation ?? this.options.ignorePunctuation) && (i = i.map((t => t.replace(/[.,/#!$%^&*;:{}=-_`~()]/g, "")))), i - })))); - this.data.data.forEach(((t, e) => { - const a = t.cells.map(((t, e) => { - const s = this.columns.settings[e]; - if (s.searchMethod || this.options.searchMethod) return t; - let i = n(t).trim(); - if (i.length) { - const t = s.sensitivity || this.options.sensitivity; - ["base", "accent"].includes(t) && (i = i.toLowerCase()), ["base", "case"].includes(t) && (i = i.normalize("NFD").replace(/\p{Diacritic}/gu, "")); - (s.ignorePunctuation ?? this.options.ignorePunctuation) && (i = i.replace(/[.,/#!$%^&*;:{}=-_`~()]/g, "")) - } - const a = s.searchItemSeparator || this.options.searchItemSeparator; - return a ? i.split(a) : [i] - })); - i.every(((e, i) => e.find(((e, n) => { - if (!e) return !1; - const o = this.columns.settings[n].searchMethod || this.options.searchMethod; - return o ? o(e, a[n], t, n, s[i].source) : e.find((t => a[n].find((e => e.includes(t))))) - })))) && this._searchData.push(e) - })), this.wrapperDOM.classList.add("search-results"), this._searchData.length ? this.update() : (this.wrapperDOM.classList.remove("search-results"), this.setMessage(this.options.labels.noResults)), this.emit("datatable.multisearch", s, this._searchData) - } - page(t, e = !1) { - return this.emit("datatable.page:before", t), t !== this._currentPage && (isNaN(t) || (this._currentPage = t), !(t > this.pages.length || t < 0) && (this._renderPage(e), this._renderPagers(), void this.emit("datatable.page", t))) - } - insert(e) { - let s = []; - if (Array.isArray(e)) { - const t = this.data.headings.map((t => t.data ? String(t.data) : t.text)); - e.forEach(((e, i) => { - const n = []; - Object.entries(e).forEach((([e, s]) => { - const a = t.indexOf(e); - a > -1 ? n[a] = Z(s, this.columns.settings[a]) : this.hasHeadings || this.hasRows || 0 !== i || (n[t.length] = Z(s, this.columns.settings[t.length]), t.push(e), this.data.headings.push(G(e))) - })), s.push({ - cells: n - }) - })) - } else t(e) && (!e.headings || this.hasHeadings || this.hasRows ? e.data && Array.isArray(e.data) && (s = e.data.map((t => { - let e, s; - return Array.isArray(t) ? (e = {}, s = t) : (e = t.attributes, s = t.cells), { - attributes: e, - cells: s.map(((t, e) => Z(t, this.columns.settings[e]))) - } - }))) : this.data = K(e, void 0, this.columns.settings, this.options.type, this.options.format)); - s.length && s.forEach((t => this.data.data.push(t))), this.hasHeadings = Boolean(this.data.headings.length), this.columns._state.sort && this.columns.sort(this.columns._state.sort.column, this.columns._state.sort.dir, !0), this.update(!0) - } - refresh() { - if (this.emit("datatable.refresh:before"), this.options.searchable) { - const t = l(this.options.classes.input); - Array.from(this.wrapperDOM.querySelectorAll(t)).forEach((t => t.value = "")), this._searchQueries = [] - } - this._currentPage = 1, this.onFirstPage = !0, this.update(!0), this.emit("datatable.refresh") - } - print() { - const t = s("table"); - let e = B(this._tableAttributes, this.data.headings, this.data.data.map(((t, e) => ({ - row: t, - index: e - }))), this.columns.settings, this.columns._state, !1, this.options, { - noColumnWidths: !0, - unhideHeader: !0 - }, this._tableFooters, this._tableCaptions); - if (this.options.tableRender) { - const t = this.options.tableRender(this.data, e, "print"); - t && (e = t) - } - const i = this._dd.diff({ - nodeName: "TABLE" - }, e); - this._dd.apply(t, i); - const n = window.open(); - n.document.body.appendChild(t), n.print() - } - setMessage(t) { - const e = this.data.headings.filter(((t, e) => !this.columns.settings[e]?.hidden)).length || 1; - this.options.classes.empty?.split(" ").forEach((t => this.wrapperDOM.classList.add(t))), this._label && (this._label.innerHTML = ""), this.totalPages = 0, this._renderPagers(); - let s = { - nodeName: "TABLE", - attributes: this._tableAttributes, - childNodes: [{ - nodeName: "THEAD", - childNodes: [q(this.data.headings, this.columns.settings, this.columns._state, this.options, {})] - }, { - nodeName: "TBODY", - childNodes: [{ - nodeName: "TR", - childNodes: [{ - nodeName: "TD", - attributes: { - class: this.options.classes.empty, - colspan: String(e) - }, - childNodes: [{ - nodeName: "#text", - data: t - }] - }] - }] - }] - }; - if (this._tableFooters.forEach((t => s.childNodes.push(t))), this._tableCaptions.forEach((t => s.childNodes.push(t))), s.attributes.class = c(s.attributes.class, this.options.classes.table), this.options.tableRender) { - const t = this.options.tableRender(this.data, s, "message"); - t && (s = t) - } - const i = this._dd.diff(this._virtualDOM, s); - this._dd.apply(this.dom, i), this._virtualDOM = s - } - on(t, e) { - this._events[t] = this._events[t] || [], this._events[t].push(e) - } - off(t, e) { - t in this._events != !1 && this._events[t].splice(this._events[t].indexOf(e), 1) - } - emit(t, ...e) { - if (t in this._events != !1) - for (let s = 0; s < this._events[t].length; s++) this._events[t][s](...e) - } - }, exports.addColumnFilter = function(t, e = {}) { - const s = new lt(t, e); - return t.initialized ? s.init() : t.on("datatable.init", (() => s.init())), s - }, exports.convertCSV = function(e) { - let s; - if (!t(e)) return !1; - const i = { - lineDelimiter: "\n", - columnDelimiter: ",", - removeDoubleQuotes: !1, - ...e - }; - if (i.data.length) { - s = { - data: [] - }; - const t = i.data.split(i.lineDelimiter); - if (t.length && (i.headings && (s.headings = t[0].split(i.columnDelimiter), i.removeDoubleQuotes && (s.headings = s.headings.map((t => t.trim().replace(/(^"|"$)/g, "")))), t.shift()), t.forEach(((t, e) => { - s.data[e] = []; - const n = t.split(i.columnDelimiter); - n.length && n.forEach((t => { - i.removeDoubleQuotes && (t = t.trim().replace(/(^"|"$)/g, "")), s.data[e].push(t) - })) - }))), s) return s - } - return !1 - }, exports.convertJSON = function(s) { - let i; - if (!t(s)) return !1; - const n = { - data: "", - ...s - }; - if (n.data.length || t(n.data)) { - const t = !!e(n.data) && JSON.parse(n.data); - if (t ? (i = { - headings: [], - data: [] - }, t.forEach(((t, e) => { - i.data[e] = [], Object.entries(t).forEach((([t, s]) => { - i.headings.includes(t) || i.headings.push(t), i.data[e].push(s) - })) - }))) : console.warn("That's not valid JSON!"), i) return i - } - return !1 - }, exports.createElement = s, exports.exportCSV = function(e, s = {}) { - if (!e.hasHeadings && !e.hasRows) return !1; - if (!t(s)) return !1; - const i = { - download: !0, - skipColumn: [], - lineDelimiter: "\n", - columnDelimiter: ",", - ...s - }, - a = t => !i.skipColumn.includes(t) && !e.columns.settings[t]?.hidden, - o = e.data.headings.filter(((t, e) => a(e))).map((t => t.text ?? t.data)); - let r; - if (i.selection) - if (Array.isArray(i.selection)) { - r = []; - for (let t = 0; t < i.selection.length; t++) r = r.concat(e.pages[i.selection[t] - 1].map((t => t.row))) - } else r = e.pages[i.selection - 1].map((t => t.row)); - else r = e.data.data; - let l = []; - if (l[0] = o, l = l.concat(r.map((t => t.cells.filter(((t, e) => a(e))).map((t => n(t)))))), l.length) { - let t = ""; - if (l.forEach((e => { - e.forEach((e => { - "string" == typeof e && (e = (e = (e = (e = (e = e.trim()).replace(/\s{2,}/g, " ")).replace(/\n/g, " ")).replace(/"/g, '""')).replace(/#/g, "%23")).includes(",") && (e = `"${e}"`), t += e + i.columnDelimiter - })), t = t.trim().substring(0, t.length - 1), t += i.lineDelimiter - })), t = t.trim().substring(0, t.length - 1), i.download) { - const e = document.createElement("a"); - e.href = encodeURI(`data:text/csv;charset=utf-8,${t}`), e.download = `${i.filename||"datatable_export"}.csv`, document.body.appendChild(e), e.click(), document.body.removeChild(e) - } - return t - } - return !1 - }, exports.exportJSON = function(e, s = {}) { - if (!e.hasHeadings && !e.hasRows) return !1; - if (!t(s)) return !1; - const i = { - download: !0, - skipColumn: [], - replacer: null, - space: 4, - ...s - }, - a = t => !i.skipColumn.includes(t) && !e.columns.settings[t]?.hidden; - let o; - if (i.selection) - if (Array.isArray(i.selection)) { - o = []; - for (let t = 0; t < i.selection.length; t++) o = o.concat(e.pages[i.selection[t] - 1].map((t => t.row))) - } else o = e.pages[i.selection - 1].map((t => t.row)); - else o = e.data.data; - const r = o.map((t => t.cells.filter(((t, e) => a(e))).map((t => n(t))))), - l = e.data.headings.filter(((t, e) => a(e))).map((t => t.text ?? String(t.data))); - if (r.length) { - const t = []; - r.forEach(((e, s) => { - t[s] = t[s] || {}, e.forEach(((e, i) => { - t[s][l[i]] = e - })) - })); - const e = JSON.stringify(t, i.replacer, i.space); - if (i.download) { - const t = new Blob([e], { - type: "data:application/json;charset=utf-8" - }), - s = URL.createObjectURL(t), - n = document.createElement("a"); - n.href = s, n.download = `${i.filename||"datatable_export"}.json`, document.body.appendChild(n), n.click(), document.body.removeChild(n), URL.revokeObjectURL(s) - } - return e - } - return !1 - }, exports.exportSQL = function(e, s = {}) { - if (!e.hasHeadings && !e.hasRows) return !1; - if (!t(s)) return !1; - const i = { - download: !0, - skipColumn: [], - tableName: "myTable", - ...s - }, - a = t => !i.skipColumn.includes(t) && !e.columns.settings[t]?.hidden; - let o = []; - if (i.selection) - if (Array.isArray(i.selection)) - for (let t = 0; t < i.selection.length; t++) o = o.concat(e.pages[i.selection[t] - 1].map((t => t.row))); - else o = e.pages[i.selection - 1].map((t => t.row)); - else o = e.data.data; - const r = o.map((t => t.cells.filter(((t, e) => a(e))).map((t => n(t))))), - l = e.data.headings.filter(((t, e) => a(e))).map((t => t.text ?? String(t.data))); - if (r.length) { - let t = `INSERT INTO \`${i.tableName}\` (`; - if (l.forEach((e => { - t += `\`${e}\`,` - })), t = t.trim().substring(0, t.length - 1), t += ") VALUES ", r.forEach((e => { - t += "(", e.forEach((e => { - t += "string" == typeof e ? `"${e}",` : `${e},` - })), t = t.trim().substring(0, t.length - 1), t += ")," - })), t = t.trim().substring(0, t.length - 1), t += ";", i.download && (t = `data:application/sql;charset=utf-8,${t}`), i.download) { - const e = document.createElement("a"); - e.href = encodeURI(t), e.download = `${i.filename||"datatable_export"}.sql`, document.body.appendChild(e), e.click(), document.body.removeChild(e) - } - return t - } - return !1 - }, exports.exportTXT = function(e, s = {}) { - if (!e.hasHeadings && !e.hasRows) return !1; - if (!t(s)) return !1; - const i = { - download: !0, - skipColumn: [], - lineDelimiter: "\n", - columnDelimiter: ",", - ...s - }, - a = t => !i.skipColumn.includes(t) && !e.columns.settings[t]?.hidden, - o = e.data.headings.filter(((t, e) => a(e))).map((t => t.text ?? t.data)); - let r; - if (i.selection) - if (Array.isArray(i.selection)) { - r = []; - for (let t = 0; t < i.selection.length; t++) r = r.concat(e.pages[i.selection[t] - 1].map((t => t.row))) - } else r = e.pages[i.selection - 1].map((t => t.row)); - else r = e.data.data; - let l = []; - if (l[0] = o, l = l.concat(r.map((t => t.cells.filter(((t, e) => a(e))).map((t => n(t)))))), l.length) { - let t = ""; - if (l.forEach((e => { - e.forEach((e => { - "string" == typeof e && (e = (e = (e = (e = (e = e.trim()).replace(/\s{2,}/g, " ")).replace(/\n/g, " ")).replace(/"/g, '""')).replace(/#/g, "%23")).includes(",") && (e = `"${e}"`), t += e + i.columnDelimiter - })), t = t.trim().substring(0, t.length - 1), t += i.lineDelimiter - })), t = t.trim().substring(0, t.length - 1), i.download && (t = `data:text/csv;charset=utf-8,${t}`), i.download) { - const e = document.createElement("a"); - e.href = encodeURI(t), e.download = `${i.filename||"datatable_export"}.txt`, document.body.appendChild(e), e.click(), document.body.removeChild(e) - } - return t - } - return !1 - }, exports.isJson = e, exports.isObject = t, exports.makeEditable = function(t, e = {}) { - const s = new ot(t, e); - return t.initialized ? s.init() : t.on("datatable.init", (() => s.init())), s - }; - - - }).call(this) - }).call(this, typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) - }, {}] - }, {}, [1])(1) -}); diff --git a/templates/404.html.j2 b/templates/404.html.j2 deleted file mode 100644 index 9b0c021607..0000000000 --- a/templates/404.html.j2 +++ /dev/null @@ -1,23 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title -%} - Uh oh! Page not found -{%- endblock title %} -{% block main %} -

          The requested page was not found

          -{% endblock main %} diff --git a/templates/alert_troubleshooting/detail.html.j2 b/templates/alert_troubleshooting/detail.html.j2 deleted file mode 100644 index 25b582c902..0000000000 --- a/templates/alert_troubleshooting/detail.html.j2 +++ /dev/null @@ -1,54 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title -%} - Troubleshooting — {{ alert_info.label }} -{%- endblock title %} -{% block style %} - -{% endblock style %} -{% block main %} -

          - troubleshoot - {{ alert_info.label }} -

          -
          - - -
          - {% set plugin_base_raw = url_for('troubleshooting_index') | string %} - {% set plugin_base = plugin_base_raw[:-1] if plugin_base_raw.endswith('/') else plugin_base_raw %} - {% if snippets %} -
          - {% for snippet in snippets %} - {% include "alert_troubleshooting/partials/snippet-card.html.j2" %} - {% endfor %} -
          - {% else %} -

          No snippets are associated with this alert.

          - {% endif %} -{% endblock main %} -{% block behaviours_optimized_extra %} - - -{% endblock behaviours_optimized_extra %} diff --git a/templates/alert_troubleshooting/index.html.j2 b/templates/alert_troubleshooting/index.html.j2 deleted file mode 100644 index d80c8b278f..0000000000 --- a/templates/alert_troubleshooting/index.html.j2 +++ /dev/null @@ -1,77 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title -%} - Alert Troubleshooting -{%- endblock title %} -{% block style %} - -{% endblock style %} -{% block main %} - {% set plugin_base_raw = url_for('troubleshooting_index') | string %} - {% set plugin_base = plugin_base_raw[:-1] if plugin_base_raw.endswith('/') else plugin_base_raw %} -

          - Select an alert to view troubleshooting steps and diagnostic snippets - grouped by database service type. -

          - {% set has_alerts = grouped_alerts | length > 0 %} - {% if has_alerts %} -
          - {% for svc in alert_service_types %} - {% if svc in grouped_alerts %} - {% set alerts = grouped_alerts[svc] %} -
          - - - database - {{ svc.label }} - - {{ alerts | length }} - expand_more - -
          - -
          -
          - {% endif %} - {% endfor %} -
          - {% else %} -

          - No alert troubleshooting guides available. Add - alerts and service_type frontmatter - fields to snippets to surface them here. -

          - {% endif %} -{% endblock main %} -{% block behaviours_optimized_extra %} - -{% endblock behaviours_optimized_extra %} diff --git a/templates/alert_troubleshooting/partials/snippet-card.html.j2 b/templates/alert_troubleshooting/partials/snippet-card.html.j2 deleted file mode 100644 index 014b7e3d0f..0000000000 --- a/templates/alert_troubleshooting/partials/snippet-card.html.j2 +++ /dev/null @@ -1,60 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% set form_id = "snippetForm-" ~ snippet.filename | replace(".", "-") | replace("/", "-") %} -{% set execute_url = url_for('troubleshooting_execute').include_query_params(snippet_filename=snippet.filename) | string %} -
          -
          -

          {{ snippet.title }}

          - {% if snippet.description %}

          {{ snippet.description }}

          {% endif %} -
          -
          - {% if snippet.is_approved and snippet.can_execute %} - {{ snippet.to_form(form_action=execute_url, form_id=form_id) | safe }} - - {% elif snippet.is_approved %} - - warning - Execution is disabled - {%- if snippet.execution_interpreter is none -%} - : no interpreter configured - {%- endif -%} - - {% else %} - - unpublished - Snippet is not approved - - {% endif %} -
          - -
          diff --git a/templates/alerts/index.html.j2 b/templates/alerts/index.html.j2 deleted file mode 100644 index 2843c70f0f..0000000000 --- a/templates/alerts/index.html.j2 +++ /dev/null @@ -1,278 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title -%} - Alert Templates -{%- endblock title %} -{% block style %} - -{% endblock style %} -{% block behaviours_optimized_extra %} - -{% endblock behaviours_optimized_extra %} -{% block sticky %} - -{% endblock sticky %} -{% block main %} -
          -
          -

          - View and manage alert templates. Templates define alerting rules that - can be pushed to PMM for monitoring. -

          - {% if all_templates %} - - - - - - - - - - - - - {% for template in all_templates %} - - - - - - - - - {% endfor %} - -
          - - NameServiceSeverityPresent in PMMDetails
          - - {{ template.name }}{{ template.service_type.label }} - {{ template.severity.value | title }} - - {% if pmm_present_names is none %} - Unknown - {% elif template.name in pmm_present_names %} - Present - {% else %} - Not Present - {% endif %} - - -
          - {% for template in all_templates %} - -
          -
          - -
          -
          -
          -
          Service Type
          -
          - {{ template.service_type.label }} -
          -
          Severity
          -
          - {{ template.severity.value | title }} -
          -
          Default Threshold
          -
          - {{ template.default_threshold }} -
          -
          Description
          -
          - {{ template.description }} -
          -
          Summary
          -
          - {{ template.summary }} -
          -
          Expression
          -
          - {{ template.expression }} -
          -
          -
          -
          - -
          -
          -
          - {% endfor %} - - {% else %} -

          No alert templates found.

          - {% endif %} -
          - -
          - -
          -
          -

          Confirm Restore

          -
          -
          -

          - This will delete all existing alert rules - and recreate them from the backup taken on - . -

          -

          This action cannot be undone. Continue?

          -
          -
          - - -
          -
          -
          - -
          -
          -

          - Backup — -

          -
          -
          -

          Loading…

          -
          -
          - -
          -
          -
          -{% endblock main %} diff --git a/templates/alters/details.html.j2 b/templates/alters/details.html.j2 deleted file mode 100644 index 02ceaabcba..0000000000 --- a/templates/alters/details.html.j2 +++ /dev/null @@ -1,194 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title -%} - Schema Change - {{ task.name }} -{%- endblock title %} -{% block style %} - - - - -{% endblock style %} -{% block behaviours_optimized_extra %} - - - - -{% endblock behaviours_optimized_extra %} -{% block main %} -

          Overview

          - - - - - - - - - - - - - - - - - - - - - - - - - - -
          HostnameTableCreated atUpdated atCreated byLast Updated by
          {{ task.hostname }}{{ task.table }}{{ task.created_at }}{{ task.updated_at }}{{ user_id_to_username.get(task["created_by"], task["created_by"]) or '' }}{{ user_id_to_username.get(task["last_updated_by"], task["last_updated_by"]) or '' }}
          - {% include "tasks/partials/run-python-form-alters.html.j2" %} - {% include "alters/partials/edit-form.html.j2" %} - {% if running_tasks %} -
          -

          Running

          - {% include "tasks/partials/running-tasks.html.j2" %} -
          - {% endif %} -
          -

          Execution

          -
          -
          Parameters
          - - - {% for k, v in task.meta.items() %} - {% if not k.startswith('_') %} - - - - - {% endif %} - {% endfor %} - -
          {{ k }} - {% if v is mapping %} -
            - {% for j, w in v.items() %}
          • {{ j }}: {{ w }}
          • {% endfor %} -
          - {% elif v is iterable and v is not string %} - {# TODO: Refactor all these weird tests #} -
            - {% for j, w in v[0].items() %}
          • {{ j }}: {{ w }}
          • {% endfor %} -
          - {% else %} - {{ v }} - {% endif %} -
          -
          -
          -
          -
          -

          PII Anonymization

          -

          The following sensitive data types are selected for anonymization:

          - {% if task.entities %} - {% for entity, value in task.entities.items() %}{{ entity }}, {% endfor %} - {% else %} -

          No entities selected.

          - {% endif %} -
          -
          -

          Stats

          - {% if stats and stats.total %} - - - - - - - - - - - - - - - - - - - - - - -
          Aggregated statistics for {{ task_name }}
          ExecutionsSucceededFailedLast Finished - Avg Duration (s) - - Last Duration (s) -
          {{ stats.total }}{{ stats.status.pass }}{{ stats.status.fail }}{{ stats.last_finished_at }}{{ stats.duration.average_seconds or 0|round(3) }}{{ stats.duration.last_seconds or 0|round(3) }}
          - {% else %} -

          No data

          - {% endif %} -
          -
          -
          - {% set pending_tasks = history|selectattr("status", "equalto", "pending")|list %} - {% if pending_tasks %} -
          -

          Pending

          - {% include "tasks/partials/pending-tasks.html.j2" %} -
          -
          -
          - {% endif %} -
          -

          Pre Checks History

          - {% set history_tasks = history_pre_checks %} - {% include "tasks/partials/completed-tasks.html.j2" %} -
          -
          -
          -
          -

          Dry Run History

          - {% set history_tasks = history_dry_run %} - {% include "tasks/partials/completed-tasks.html.j2" %} -
          -
          -
          -
          -

          History

          - {% set history_tasks = history %} - {% include "tasks/partials/completed-tasks.html.j2" %} -
          -{% endblock main %} diff --git a/templates/alters/index.html.j2 b/templates/alters/index.html.j2 deleted file mode 100644 index 63b016a031..0000000000 --- a/templates/alters/index.html.j2 +++ /dev/null @@ -1,158 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title %} - Schema Change -{% endblock title %} -{% block style %} - {% if history_tasks or running_tasks %} - - {% endif %} - {% if tasks %} - - {% endif %} -{% endblock style %} -{% block behaviours_optimized_extra %} - - {% if history_tasks or running_tasks %} - - {% endif %} - {% if tasks %} - - {% endif %} -{% endblock behaviours_optimized_extra %} -{% block main %} - -
          -

          Schema Change

          -

          - Perform safe, online schema changes for MySQL databases using pt-online-schema-change. For more details, please check the documentation. If you run into an issue, please submit it in Github. -

          -
          -
          - {% if not tasks and not history_tasks %} - {% set modal_required = False %} - -
          - {% if services|length < 1 %} -

          Unable to find suitable database instances.

          - {% elif executor_hosts|length < 1 %} -

          Unable to find suitable executor hosts.

          - {% else %} - - {% endif %} -
          - {% else %} - {% set modal_required = True %} - - {% if running_tasks %} - {% set default_tab = "running-tasks" %} - {% else %} - {% set default_tab = "saved-tasks" %} - {% endif %} - -
          - - {% if running_tasks %} -
          - {% include "tasks/partials/running-tasks.html.j2" %} -
          - {% endif %} - -
          - {% include "alters/partials/saved-tasks.html.j2" %} - -
          - {% if executor_hosts|length < 1 %} -

          Unable to find suitable executor hosts.

          - {% else %} - - {% endif %} -
          - - - - -
          - {% endif %} -
          - - {% if executor_hosts|length >= 1 %} - {% include "alters/partials/create-form.html.j2" %} - {% endif %} -{% endblock main %} diff --git a/templates/alters/partials/create-form.html.j2 b/templates/alters/partials/create-form.html.j2 deleted file mode 100644 index 440e11ee38..0000000000 --- a/templates/alters/partials/create-form.html.j2 +++ /dev/null @@ -1,966 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} - -
          - -
          -

          Configure a schema change

          -
          -
          - -
          - Main options -
          - Executor Host -
          -
          - {% set hostname_title = "Select the hostname of the target system for task execution." %} - -
          -
          -
          -
          -
          - Change details -
          -
          - {% set task_name_title = "Type the name of task to be created." %} - - -
          -
          -
          - {% set pre_cnf_title = "Path on executor with [client] user/password. Pre-checks use this path. Execute/dry-run use the same path only if not ~/.my.cnf (then --defaults-file is passed to pt-online-schema-change)." %} - - -
          -
          -
          - {% set service_id_title = "Select the database host from the inventory to connect to." %} - -
          -
          -
          -
          -
          - Data -
          -
          - - -
          -
          -
          - {% set schema_id_title = "Select the database schema on which the task will operate." %} - -
          - -
          -
          - {% set table_id_title = "Select the table within the schema to be altered." %} - -
          - -
          -
          -
          -
          -
          - -
          -
          -
          -
          -
          -
          - {% set alter_title = "Specify schema modifications (excluding ALTER TABLE keywords). Separate multiple modifications with commas." %} - - -
          -
          -
          -
          - Advanced options -
          -
          - {% set recursion_method_title = "Select the method for handling recursion to discover replicas (e.g., processlist, hosts, dsn)." %} - -
          -
          -
          - {% set dsn_table_title = "Type the DSN to use for the recursion method when set to 'dsn'. Format: D=database,t=table (e.g., D=percona,t=dsns) or full DSN with host: h=HOST,D=database,t=table" %} - - -
          -
          -
          -
          -
          -
          - {% set pause_file_title = "Type a file path. Execution will pause while this file exists." %} - - -
          -
          -
          -
          -
          - {% set new_table_name_title = "Type the new table name before it is swapped. Use %T to include the original table name." %} - - -
          -
          -
          -
          - -
          - {% set progress_title = "Type progress report settings. Use a comma-separated value: the first part defines the type (percentage, time, or iterations), and the second part specifies the frequency (percentage, seconds, or iterations)." %} - - -
          -
          -
          -
          - - - - -
          -
          -
          -
          - {% set tries_title = "Type the number of retries and wait times for critical operations in the format: operation:tries:wait. Defaults: create_triggers:10:1, drop_triggers:10:1, copy_rows:10:0.25." %} - - -
          -
          -
          -
          -
          - {% set set_vars_titlie = "Set MySQL variables as a comma-separated list of variable=value pairs. Defaults: wait_timeout=10000, innodb_lock_wait_timeout=1, lock_wait_timeout=60." %} - - -
          -
          -
          -
          -
          - {% set critical_load_title = "Type a comma-separated list of MySQL status variables and thresholds to monitor. The tool will abort if the load exceeds these thresholds." %} - - -
          -
          - {% set max_load_title = "Type a comma-separated list of MySQL load thresholds to pause operation if exceeded. Default: Threads_running=25." %} - - -
          -
          - {% set max_flow_ctl_title = "Check average time cluster spent pausing for Flow Control and make tool pause if it goes over the percentage indicated in the option.." %} - - -
          -
          -
          -
          -
          - {% set chunk_time_title = "Set the target execution time (in seconds) for each data-copy query. Default: 0.5 seconds." %} - - -
          -
          -
          -
          -
          - {% set max_lag_title = "Set the maximum allowed replica lag before pausing data copy. Default: 1s." %} - - -
          -
          -
          -
          - Extra Arguments -
          -
          - {% set extra_args_title = "Additional command-line arguments to append to the pt-online-schema-change command. For example: --where='id > 1000' or --null-to-not-null" %} - - -
          -
          -
          - {% include "tasks/partials/create-form-alert-on-failure-input.html.j2" %} - {% include "tasks/partials/create-form-connectivity-check-input.html.j2" %} -
          -
          - - -
          -
          -
          - - diff --git a/templates/alters/partials/edit-form.html.j2 b/templates/alters/partials/edit-form.html.j2 deleted file mode 100644 index 8255b29feb..0000000000 --- a/templates/alters/partials/edit-form.html.j2 +++ /dev/null @@ -1,1111 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} - -
          - -
          -

          Edit task

          -
          -
          -

          - You are about to edit the task . -

          -
          -
          - Executor Host -
          -
          - {% set hostname_title = "Select the hostname of the target system for task execution." %} - -
          -
          -
          -
          - Change details -
          -
          - {% set task_name_title = "Type the name of task to be created." %} - - -
          -
          -
          - {% set pre_cnf_title = "Path on executor with [client] user/password. Pre-checks use this path. Execute/dry-run use the same path only if not ~/.my.cnf (then --defaults-file is passed to pt-online-schema-change)." %} - - -
          -
          -
          - {% set service_id_title = "Select the database host from the inventory to connect to." %} - -
          -
          -
          -
          -
          - Data -
          -
          - - -
          -
          -
          - {% set schema_id_title = "Select the database schema on which the task will operate." %} - -
          - -
          -
          - {% set table_id_title = "Select the table within the schema to be altered." %} - -
          - -
          -
          -
          -
          -
          - -
          -
          -
          -
          -
          -
          - {% set alter_title = "Specify schema modifications (excluding ALTER TABLE keywords). Separate multiple modifications with commas." %} - - -
          -
          -
          -
          - Advanced options -
          -
          - {% set recursion_method_title = "Select the method for handling recursion to discover replicas (e.g., processlist, hosts, dsn)." %} - -
          -
          -
          - {% set dsn_table_title = "Type the DSN to use for the recursion method when set to 'dsn'. Format: D=database,t=table (e.g., D=percona,t=dsns) or full DSN with host: h=HOST,D=database,t=table" %} - - -
          -
          -
          -
          -
          -
          - {% set pause_file_title = "Type a file path. Execution will pause while this file exists." %} - - -
          -
          -
          -
          -
          - {% set new_table_name_title = "Type the new table name before it is swapped. Use %T to include the original table name." %} - - -
          -
          -
          -
          - -
          - {% set progress_title = "Type progress report settings. Use a comma-separated value: the first part defines the type (percentage, time, or iterations), and the second part specifies the frequency (percentage, seconds, or iterations)." %} - - -
          -
          -
          -
          - - - - -
          -
          -
          -
          - {% set tries_title = "Type the number of retries and wait times for critical operations in the format: operation:tries:wait. Defaults: create_triggers:10:1, drop_triggers:10:1, copy_rows:10:0.25." %} - - -
          -
          -
          -
          -
          - {% set set_vars_titlie = "Set MySQL variables as a comma-separated list of variable=value pairs. Defaults: wait_timeout=10000, innodb_lock_wait_timeout=1, lock_wait_timeout=60." %} - - -
          -
          -
          -
          -
          - {% set critical_load_title = "Type a comma-separated list of MySQL status variables and thresholds to monitor. The tool will abort if the load exceeds these thresholds." %} - - -
          -
          - {% set max_load_title = "Type a comma-separated list of MySQL load thresholds to pause operation if exceeded. Default: Threads_running=25." %} - - -
          -
          - {% set max_flow_ctl_title = "Check average time cluster spent pausing for Flow Control and make tool pause if it goes over the percentage indicated in the option.." %} - - -
          -
          -
          -
          -
          - {% set chunk_time_title = "Set the target execution time (in seconds) for each data-copy query. Default: 0.5 seconds." %} - - -
          -
          -
          -
          -
          - {% set max_lag_title = "Set the maximum allowed replica lag before pausing data copy. Default: 1s." %} - - -
          -
          -
          -
          - Extra Arguments -
          -
          - {% set extra_args_title = "Additional command-line arguments to append to the pt-online-schema-change command. For example: --where='id > 1000' or --null-to-not-null" %} - - -
          -
          -
          - {% include "tasks/partials/create-form-alert-on-failure-input.html.j2" %} -
          -
          -
          - - -
          -
          - -
          -{% set source_data = { "schema": task['schema_name'], "table": task['table_name']} %} - - diff --git a/templates/alters/partials/saved-tasks.html.j2 b/templates/alters/partials/saved-tasks.html.j2 deleted file mode 100644 index 76d2390254..0000000000 --- a/templates/alters/partials/saved-tasks.html.j2 +++ /dev/null @@ -1,64 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% if tasks %} - {# - -
          -

          Debug - Task Data:

          - {% for task in tasks %} -
          - Task {{ loop.index }}: -
          {{ task | tojson(indent=2) }}
          -
          - {% endfor %} -
          - -#} - - - - - - - - - - - - - {% for task in tasks %} - {% if not task.parent %} - - - - - - - - - {% endif %} - {% endfor %} - -
          NameHostTableCreated byLast Updated byOptions
          - {{ task.name }} - {% include "tasks/partials/connectivity-warning-icon.html.j2" %} - {{ task.hostname }}{{ task.table }}{{ user_id_to_username.get(task["created_by"], task["created_by"]) or '' }}{{ user_id_to_username.get(task["last_updated_by"], task["last_updated_by"]) or '' }}{% include "tasks/partials/saved-tasks-actions-column-alters.html.j2" %}
          -{% include "tasks/partials/scheduled-execution-dialog.html.j2" %} -{% else %} - -No data -{% endif %} diff --git a/templates/archiver/details.html.j2 b/templates/archiver/details.html.j2 deleted file mode 100644 index 8986c46a2e..0000000000 --- a/templates/archiver/details.html.j2 +++ /dev/null @@ -1,207 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title -%} - Data Archiver - {{ task.name }} -{%- endblock title %} -{% block style %} - - - - -{% endblock style %} -{% block behaviours_optimized_extra %} - - - {% if history %} - - {% endif %} - -{% endblock behaviours_optimized_extra %} -{% block main %} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
          Overview
          HostnameSource TableDestinationCreated atUpdated atCreated byLast Updated by
          {{ task.hostname }} - {% if task.source_table %}{{ task.source_table }}{% endif %} - {% if task.source_query %}{{ task.source_query }}{% endif %} - - {% if task.dest_host %} -
          - Host: {{ task.dest_host }} - {% if task.dest_port %}:{{ task.dest_port }}{% endif %} -
          - {% endif %} - {% if task.dest_db %}
          DB: {{ task.dest_db }}
          {% endif %} - {% if task.dest_table %} -
          Table: {{ task.dest_table }}
          - {% elif task.dest_file %} -
          File: {{ task.dest_file }}
          - {% endif %} -
          {{ task.created_at }}{{ task.updated_at }}{{ user_id_to_username.get(task["created_by"], task["created_by"]) or '' }}{{ user_id_to_username.get(task["last_updated_by"], task["last_updated_by"]) or '' }}
          - {% include "tasks/partials/run-python-form.html.j2" %} - {% include "archiver/partials/edit-form.html.j2" %} - {% if running_tasks %} -
          -

          Running

          - {% include "tasks/partials/running-tasks.html.j2" %} -
          - {% endif %} -
          -

          Execution

          -
          -
          Parameters
          - - - {% for k, v in task.meta.items() %} - {% if not k.startswith('_') %} - - - - - {% endif %} - {% endfor %} - -
          {{ k }} - {% if v is mapping %} -
            - {% for j, w in v.items() %}
          • {{ j }}: {{ w }}
          • {% endfor %} -
          - {% elif v is iterable and v is not string %} - {# TODO: Refactor all these weird tests #} -
            - {% for j, w in v[0].items() %}
          • {{ j }}: {{ w }}
          • {% endfor %} -
          - {% else %} - {{ v|syntax_highlight(style="sas") |safe }} - {% endif %} -
          -
          -
          -
          -
          -
          -

          PII Anonymization

          -

          The following sensitive data types are selected for anonymization:

          - {% if task.entities %} - {% for entity, value in task.entities.items() %}{{ entity }}, {% endfor %} - {% else %} -

          No entities selected.

          - {% endif %} -
          -
          -

          Stats

          - {% if stats and stats.total %} - - - - - - - - - - - - - - - - - - - - - - -
          Aggregated statistics for {{ task_name }}
          ExecutionsSucceededFailedLast Finished - Avg Duration (s) - - Last Duration (s) -
          {{ stats.total }}{{ stats.status.pass }}{{ stats.status.fail }}{{ stats.last_finished_at }}{{ stats.duration.average_seconds or 0|round(3) }}{{ stats.duration.last_seconds or 0|round(3) }}
          - {% else %} -

          No data

          - {% endif %} -
          -
          -
          - {% set pending_tasks = history|selectattr("status", "equalto", "pending")|list %} - {% if pending_tasks %} -
          -

          Pending

          - {% include "tasks/partials/pending-tasks.html.j2" %} -
          -
          -
          - {% endif %} -
          -

          History

          - {% set history_tasks = history %} - {% include "tasks/partials/completed-tasks.html.j2" %} -
          -{% endblock main %} diff --git a/templates/archiver/index.html.j2 b/templates/archiver/index.html.j2 deleted file mode 100644 index d675304809..0000000000 --- a/templates/archiver/index.html.j2 +++ /dev/null @@ -1,123 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title -%} - Data Archiver -{%- endblock title %} -{% block style %} - {% if history_tasks or running_tasks %} - - {% endif %} - {% if tasks %} - - - - - {% endif %} -{% endblock style %} -{% block behaviours_optimized_extra %} - - {% if history_tasks or running_tasks %} - - {% endif %} - {% if tasks %} - - - - - - - - {% endif %} -{% endblock behaviours_optimized_extra %} -{% block main %} -

          Data Archiver

          -

          - Perform data archiving tasks, currently MySQL databases using pt-archiver. For more details, please check the documentation. If you run into an issue, please submit it in Github. -

          - {% if not tasks and not history_tasks %} - {% set modal_required = False %} - {% if services|length < 1 %} -

          Unable to find suitable database instances.

          - {% elif executor_hosts|length < 1 %} -

          Unable to find suitable executor hosts.

          - {% else %} - - {% endif %} - {% else %} - {% set modal_required = True %} - {% if running_tasks %} -
          -

          Running

          - {% include "tasks/partials/running-tasks.html.j2" %} -
          - {% endif %} -
          -

          Saved

          - {% include "archiver/partials/saved-tasks.html.j2" %} -
          - {% if executor_hosts|length < 1 %} -

          Unable to find suitable executor hosts.

          - {% else %} - - {% endif %} -
          -

          Periodic Tasks

          - {%- set detail_route = "archives_detail" %} - {% include "tasks/partials/scheduled-tasks.html.j2" %} -
          -
          -
          -
          -
          -

          Pending

          - {% include "tasks/partials/pending-tasks.html.j2" %} -
          -
          -
          -
          -

          History

          - {% include "tasks/partials/completed-tasks.html.j2" %} -
          - {% endif %} - - {% if executor_hosts|length >= 1 %} - {% include "archiver/partials/create-form.html.j2" %} - {% endif %} -{% endblock main %} diff --git a/templates/archiver/partials/create-form.html.j2 b/templates/archiver/partials/create-form.html.j2 deleted file mode 100644 index cb47e193d3..0000000000 --- a/templates/archiver/partials/create-form.html.j2 +++ /dev/null @@ -1,1193 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} - -
          - -
          -

          Configure an archiver task

          -
          -
          - -
          -
          - Executor Host - {% set hostname_title = "Select the hostname of the target system for task execution." %} -
          - -
          -
          -
          -
          - Archive Type -
          -
          - {% set task_name_title = "Type the name of task to be created." %} - - {% set swap_drop_title = "Select the drop behavior: 'Standard Purge' for basic purging, 'Swap - and Drop' to replace and drop the original table, or 'Swap-Archive-Drop' to archive before - dropping." %} -
          -
          -
          - -
          -
          -
          -
          -
          -
          - Source details -
          -
          - {% set service_id_title = "Select the database host from the inventory to connect to." %} -
          - -
          -
          -
          -
          - - -
          -
          -
          -
          - {% set source_db_id_title = "Select the source database schema from which data will be - purged." %} - -
          - -
          - {% set source_table_id_title = "Select the source table from which data will be purged." %} -
          -
          - -
          - -
          -
          - - {% set source_query_title = "Type an optional query to define the source data to be purged." %} - -
          -
          -
          -
          - {% set swp_table_suffix_title = "Select an date suffix for the swap table." %} - -
          -
          - {% set where_title = "Type a WHERE clause to limit rows for archiving (exclude the keyword - 'WHERE')." %} - -
          -
          - {% set use_index_title = "Optionally specify an index to optimize the query execution." %} - -
          -
          - {% set limit_title = "Set the number of rows to fetch and archive per statement." %} - - {% set sleep_title = "Set the sleep time (in seconds) between fetch operations. Default: 0 (no - sleep)." %} - -
          -
          - {% set extra_args_title = "Type optional additional arguments for the archive task." %} - -
          -
          - {% set disable_binlog_title = "Disable binary logging for the operation." %} - -
          -
          - {% set disable_bulk_insert_title = "Disable bulk insert for the operation. Use this when local_infile is set to OFF on the target host." %} - -
          - {% set delete_data_title = "Source rows are deleted without being written to any destination; the Destination Table/File fields are disabled." %} - -
          -
          -
          - Destination details -
          - -
          - - - - -
          - - -
          -
          - {% set dest_table_id_title = "Select the destination table for the archived data." %} -
          -
          - -
          - -
          -
          - - {% set dest_file_title = "Type the file path where the archived data will be stored." %} - -
          -
          -
          -
          -
          - {% include "tasks/partials/create-form-alert-on-failure-input.html.j2" %} - {% include "tasks/partials/create-form-connectivity-check-input.html.j2" %} -
          -
          - - -
          -
          -
          - - diff --git a/templates/archiver/partials/edit-form.html.j2 b/templates/archiver/partials/edit-form.html.j2 deleted file mode 100644 index 6e4228cbe2..0000000000 --- a/templates/archiver/partials/edit-form.html.j2 +++ /dev/null @@ -1,1355 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} - -
          - -
          -

          Edit task

          -
          -
          -

          - You are about to edit the task . -

          -
          -
          - Executor Host - {% set hostname_title = "Select the hostname of the target system for task execution." %} -
          - -
          -
          -
          -
          - Archive Type -
          -
          - {% set task_name_title = "Type the name of task to be created." %} - - {% set swap_drop_title = "Select the drop behavior: 'Standard Purge' for basic purging, 'Swap and Drop' to replace and drop the original table, or 'Swap-Archive-Drop' to archive before dropping." %} -
          -
          -
          - -
          -
          -
          -
          -
          - Source details -
          - {% set service_id_title = "Select the database host from the inventory to connect to." %} -
          - -
          -
          -
          -
          - - -
          -
          -
          -
          - {% set source_db_id_title = "Select the source database schema from which data will be purged." %} - -
          - -
          - {% set source_table_id_title = "Select the source table from which data will be purged." %} -
          -
          - -
          - -
          -
          - - {% set source_query_title = "Type an optional query to define the source data to be purged." %} - -
          -
          -
          -
          - {% set swp_table_suffix_title = "Select an date suffix for the swap table." %} - -
          -
          -
          - {% set where_title = "Type a WHERE clause to limit rows for archiving (exclude the keyword 'WHERE')." %} - -
          -
          - {% set use_index_title = "Optionally specify an index to optimize the query execution." %} - -
          -
          - {% set limit_title = "Set the number of rows to fetch and archive per statement." %} - - {% set sleep_title = "Set the sleep time (in seconds) between fetch operations. Default: 0 (no sleep)." %} - -
          -
          - {% set extra_args_title = "Type optional additional arguments for the archive task." %} - -
          -
          - {% set disable_binlog_title = "Disable binary logging for the operation." %} - -
          -
          - {% set disable_bulk_insert_title = "Disable bulk insert for the operation. Use this when local_infile is set to OFF on the target host." %} - -
          - {% set delete_data_title = "Source rows are deleted without being written to any destination; the Destination Table/File fields are disabled." %} - -
          -
          -
          - Destination details -
          - -
          - - - - -
          - - -
          -
          - {% set dest_table_id_title = "Select the destination table for the archived data." %} -
          -
          - -
          - -
          -
          - - {% set dest_file_title = "Type the file path where the archived data will be stored." %} - -
          -
          -
          -
          - {% include "tasks/partials/create-form-alert-on-failure-input.html.j2" %} -
          -
          -
          - - -
          -
          - -
          -{% set source_data = { "schema": task["source_db"], "table": task["source_table"], "dest_table": task["dest_table"], "dest_host": task.get("dest_host", ""), "dest_port": task.get("dest_port", ""), "dest_db": task.get("dest_db", "")} %} - - diff --git a/templates/archiver/partials/saved-tasks.html.j2 b/templates/archiver/partials/saved-tasks.html.j2 deleted file mode 100644 index 7d412243d3..0000000000 --- a/templates/archiver/partials/saved-tasks.html.j2 +++ /dev/null @@ -1,50 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% if tasks %} - - - - - - - - - - - - - - {% for task in tasks %} - - - - - - - - - - {% endfor %} - -
          NameHostSource TableDestinationCreated byLast edit byActions
          - {{ task.name }} - {% include "tasks/partials/connectivity-warning-icon.html.j2" %} - {{ task.hostname }}{{ task.source_table }} {{ task.source_query }}{{ task.dest_table }} {{ task.dest_file }}{{ user_id_to_username.get(task["created_by"], task["created_by"]) or '' }}{{ user_id_to_username.get(task["last_updated_by"], task["last_updated_by"]) or '' }}{% include "tasks/partials/saved-tasks-actions-column.html.j2" %}
          - {% include "tasks/partials/scheduled-execution-dialog.html.j2" %} -{% else %} - No data -{% endif %} diff --git a/templates/backup_mongo/backup/details.html.j2 b/templates/backup_mongo/backup/details.html.j2 deleted file mode 100644 index 84d2c04491..0000000000 --- a/templates/backup_mongo/backup/details.html.j2 +++ /dev/null @@ -1,200 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title -%} - PBM Backups - {{ task.name }} -{%- endblock title %} -{% block style %} - - - -{% endblock style %} -{% block behaviours_optimized_extra %} - - - -{% endblock behaviours_optimized_extra %} -{% block sticky %} -
          - Backup - Restore -
          -{% endblock sticky %} -{% block main %} -

          PBM Backups: {{ task.name }}

          -
          Executor Host: {{ task.hostname }}
          - - - - - - - - - - - - - - - - - - - - - - - - - -
          Overview
          Executor HostCreated atUpdated atCreated byLast Updated by
          {{ task.hostname }}{{ task.created_at }}{{ task.updated_at }}{{ user_id_to_username.get(task["created_by"], task["created_by"]) or '' }}{{ user_id_to_username.get(task["last_updated_by"], task["last_updated_by"]) or '' }}
          - {% include "tasks/partials/run-python-form-backup_mongo.html.j2" %} - {% if running_tasks %} -
          -

          Running

          - {% include "tasks/partials/running-tasks.html.j2" %} -
          - {% endif %} -
          -

          Configuration

          - - - {% for k, v in task.meta.items() %} - {% if k == 'config' %} - - - - - {% endif %} - {% endfor %} - -
          - {% if v is mapping %} -
            - {% for j, w in v.items() %}
          • {{ j }}: {{ w }}
          • {% endfor %} -
          - {% elif v is iterable and v is not string %} - {# TODO: Refactor all these weird tests #} -
            - {% for j, w in v[0].items() %}
          • {{ j }}: {{ w }}
          • {% endfor %} -
          - {% else %} - {{ v|syntax_highlight(style="sas") |safe }} - {% endif %} -
          -
          - {# -
          -

          Stats

          - {% if stats and stats.total %} - - - - - - - - - - - - - - - - - - - - - - -
          Aggregated statistics for {{ task_name }}
          ExecutionsSucceededFailedLast Finished - Avg Duration (s) - - Last Duration (s) -
          {{ stats.total }}{{ stats.status.pass }}{{ stats.status.fail }}{{ stats.last_finished_at }}{{ stats.duration.average_seconds or 0|round(3) }}{{ stats.duration.last_seconds or 0|round(3) }}
          - {% else %} -

          No data

          - {% endif %} -
          -#} -
          -
          - {% set pending_tasks = history|selectattr("status", "equalto", "pending")|list %} - {% if pending_tasks %} -
          -

          Pending

          - {% include "tasks/partials/pending-tasks.html.j2" %} -
          -
          -
          - {% endif %} - {% if history_logical %} -
          -

          Logical Backup Runs

          - {% set history_tasks = history_logical %} - {% include "tasks/partials/completed-tasks.html.j2" %} -
          -
          -
          - {% endif %} - {% if history_physical %} -
          -

          Physical Backup Runs

          - {% set history_tasks = history_physical %} - {% include "tasks/partials/completed-tasks.html.j2" %} -
          -
          -
          - {% endif %} - {% if history_incremental %} -
          -

          Incremental Backup Runs

          - {% set history_tasks = history_incremental %} - {% include "tasks/partials/completed-tasks.html.j2" %} -
          -
          -
          - {% endif %} -
          -

          Config Sync

          - {% set history_tasks = history %} - {% include "tasks/partials/completed-tasks.html.j2" %} -
          -{% endblock main %} diff --git a/templates/backup_mongo/backup/index.html.j2 b/templates/backup_mongo/backup/index.html.j2 deleted file mode 100644 index b555f85aa2..0000000000 --- a/templates/backup_mongo/backup/index.html.j2 +++ /dev/null @@ -1,122 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title -%} - PBM Backups -{%- endblock title %} -{% block style %} - {% if history_tasks or running_tasks %} - - {% endif %} - {% if tasks %} - - - - - {% endif %} -{% endblock style %} -{% block behaviours_optimized_extra %} - - {% if history_tasks or running_tasks %} - - {% endif %} - {% if tasks %} - - - - - - - - {% endif %} -{% endblock behaviours_optimized_extra %} -{% block sticky %} -
          - Backup - Restore -
          -{% endblock sticky %} -{% block main %} -

          PBM Backups

          -

          - Generate a PBM MongoDB backup, currently only - supporting Logical, Physical methods. -

          - {% if not tasks and not history_tasks %} - {% set modal_required = False %} - {% if executor_hosts|length < 1 %} -

          Unable to find suitable executor hosts.

          - {% else %} - - {% endif %} - {% else %} - {% set modal_required = True %} - {% if running_tasks %} -
          -

          Running

          - {% include "tasks/partials/running-tasks.html.j2" %} -
          - {% endif %} -
          -

          Configured Backups

          - {% include "backup_mongo/backup/partials/saved-tasks.html.j2" %} - {% if executor_hosts|length < 1 %} -

          Unable to find suitable executor hosts.

          - {% else %} - - {% endif %} -

          Periodic Tasks

          - {%- set detail_route = "pbm_backups_detail" %} - {% include "tasks/partials/scheduled-tasks.html.j2" %} -
          -
          -

          Pending

          - {% include "tasks/partials/pending-tasks.html.j2" %} -
          -
          -

          History

          - {% include "tasks/partials/completed-tasks.html.j2" %} -
          - {% endif %} - - {% if executor_hosts|length >= 1 %} - {% include "backup_mongo/backup/partials/create-form.html.j2" %} - {% endif %} -{% endblock main %} diff --git a/templates/backup_mongo/backup/partials/create-form.html.j2 b/templates/backup_mongo/backup/partials/create-form.html.j2 deleted file mode 100644 index 0d4bb334ed..0000000000 --- a/templates/backup_mongo/backup/partials/create-form.html.j2 +++ /dev/null @@ -1,340 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} - -
          - -
          -

          Configure a MongoDB backup task

          -
          -
          - -
          Main Options
          -
          - Executor Host - {% set hostname_title = "Select the hostname of the target system for task execution." %} - - {% if executor_hosts|length == 1 %} - {% set single_host = executor_hosts[0] %} - - - {% else %} - - {% endif %} -
          -
          - Basic Task Information -
          - {% set task_name_title = "Type the name of task to be created." %} - -
          -
          - {% set service_id_title = "Select the database host from the inventory to connect to." %} - -
          -
          - {% set credentials_path_title = "Optional: path to MongoDB URI credentials file on the Nomad node (e.g. /opt/sep/.mongodb_uri). Stored in backup config and used by the task." %} - -
          -
          - - -
          - {% include "backup_mongo/backup/partials/pbm-config.html.j2" %} - {% include "tasks/partials/create-form-alert-on-failure-input.html.j2" %} -
          -
          - - -
          -
          -
          - diff --git a/templates/backup_mongo/backup/partials/pbm-config.html.j2 b/templates/backup_mongo/backup/partials/pbm-config.html.j2 deleted file mode 100644 index b95edd9ede..0000000000 --- a/templates/backup_mongo/backup/partials/pbm-config.html.j2 +++ /dev/null @@ -1,273 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} - -
          PBM Configuration
          - -
          - 1. Storage Configuration -
          - - -
          - - - - -
          -
          - 2. Point-in-Time Recovery (PITR) Configuration - - - - - -
          -
          - 3. Backup Options - {% set backup_priority_title = "Node priority mapping for backups in YAML format. Example: \"localhost:28019\": 2.5" %} - -
          -
          - {% set backup_compression_title = "Compression method for backup snapshots. Default: s2" %} - -
          -
          - {% set backup_compression_level_title = "Compression level. Default depends on compression method. For zstd: 1-4 (default 2), for lz4: 1-16 (default 1), for gzip/pgzip: -1 to 9 (default -1)" %} - -
          -
          - {% set backup_timeouts_starting_status_title = "Wait time (in seconds) for PBM to start backups. Default: 33" %} - -
          -
          - {% set backup_oplog_span_min_title = "Duration (in minutes) of oplog slices saved with logical backup snapshot. Default: same as pitr.oplogSpanMin (10 minutes)" %} - -
          -
          - {% set backup_num_parallel_collections_title = "Number of parallel collections to process during logical backup. Default: number of CPU cores / 2" %} - -
          -
          - {% set backup_namespaces_title = "Optional comma-separated db.collection or db.* list for selective logical backups (pbm backup --ns). Physical and incremental backups ignore this field because PBM rejects --ns for those types." %} - -
          -
          - -
          - diff --git a/templates/backup_mongo/backup/partials/saved-tasks.html.j2 b/templates/backup_mongo/backup/partials/saved-tasks.html.j2 deleted file mode 100644 index 211d0b116d..0000000000 --- a/templates/backup_mongo/backup/partials/saved-tasks.html.j2 +++ /dev/null @@ -1,64 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% if tasks %} - {# - -
          -

          Debug - Task Data:

          - {% for task in tasks %} -
          - Task {{ loop.index }}: -
          {{ task | tojson(indent=2) }}
          -
          - {% endfor %} -
          - -#} - - - - - - - - - - - - - {% for task in tasks %} - {% if not task.parent %} - - - - - - - - - {% endif %} - {% endfor %} - -
          NameTargetCreated atCreated byLast Updated byActions
          - {{ task.name }} - {{ task.target }}{{ task.created_at }}{{ user_id_to_username.get(task["created_by"], task["created_by"]) or '' }}{{ user_id_to_username.get(task["last_updated_by"], task["last_updated_by"]) or '' }}{% include "tasks/partials/saved-tasks-actions-column.html.j2" %}
          - -{% else %} -

          No data

          -{% endif %} diff --git a/templates/backup_mongo/restore/details.html.j2 b/templates/backup_mongo/restore/details.html.j2 deleted file mode 100644 index b99721cbe4..0000000000 --- a/templates/backup_mongo/restore/details.html.j2 +++ /dev/null @@ -1,173 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title -%} - PBM Restores - {{ task.name }} -{%- endblock title %} -{% block style %} - - - -{% endblock style %} -{% block behaviours_optimized_extra %} - - - -{% endblock behaviours_optimized_extra %} -{% block sticky %} -
          - Backup - Restore -
          -{% endblock sticky %} -{% block main %} -

          PBM Restores: {{ task.name }}

          -
          Executor Host: {{ task.hostname }}
          - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
          Overview
          Executor HostRestore TypeBackup SourceCreated atUpdated atCreated byLast Updated by
          {{ task.hostname }}{{ task.restore_type }}{{ task.backup_source }}{{ task.created_at }}{{ task.updated_at }}{{ user_id_to_username.get(task["created_by"], task["created_by"]) or '' }}{{ user_id_to_username.get(task["last_updated_by"], task["last_updated_by"]) or '' }}
          - {% include "tasks/partials/run-python-form-restore_mongo.html.j2" %} - {% if running_tasks %} -
          -

          Running

          - {% include "tasks/partials/running-tasks.html.j2" %} -
          - {% endif %} -
          -

          Configuration

          - - - {% for k, v in task.meta.items() %} - {% if k == 'config' %} - - - - - {% endif %} - {% endfor %} - -
          - {% if v is mapping %} -
            - {% for j, w in v.items() %}
          • {{ j }}: {{ w }}
          • {% endfor %} -
          - {% elif v is iterable and v is not string %} - {# TODO: Refactor all these weird tests #} -
            - {% for j, w in v[0].items() %}
          • {{ j }}: {{ w }}
          • {% endfor %} -
          - {% else %} - {{ v|syntax_highlight(style="sas") |safe }} - {% endif %} -
          -
          -
          -
          - {% set pending_tasks = history|selectattr("status", "equalto", "pending")|list %} - {% if pending_tasks %} -
          -

          Pending

          - {% include "tasks/partials/pending-tasks.html.j2" %} -
          -
          -
          - {% endif %} - {% if history_logical %} -
          -

          Logical Restore Runs

          - {% set history_tasks = history_logical %} - {% include "tasks/partials/completed-tasks.html.j2" %} -
          -
          -
          - {% endif %} - {% if history_physical %} -
          -

          Physical Restore Runs

          - {% set history_tasks = history_physical %} - {% include "tasks/partials/completed-tasks.html.j2" %} -
          -
          -
          - {% endif %} - {% if history_pbm_list %} -
          -

          PBM List Runs

          - {% set history_tasks = history_pbm_list %} - {% include "tasks/partials/completed-tasks.html.j2" %} -
          -
          -
          - {% endif %} - {% if history_force_resync %} -
          -

          Force Resync Runs

          - {% set history_tasks = history_force_resync %} - {% include "tasks/partials/completed-tasks.html.j2" %} -
          -
          -
          - {% endif %} -
          -

          Config Sync

          - {% set history_tasks = history %} - {% include "tasks/partials/completed-tasks.html.j2" %} -
          -{% endblock main %} diff --git a/templates/backup_mongo/restore/index.html.j2 b/templates/backup_mongo/restore/index.html.j2 deleted file mode 100644 index 55325360f1..0000000000 --- a/templates/backup_mongo/restore/index.html.j2 +++ /dev/null @@ -1,122 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title -%} - PBM Restores -{%- endblock title %} -{% block style %} - {% if history_tasks or running_tasks %} - - {% endif %} - {% if tasks %} - - - - - {% endif %} -{% endblock style %} -{% block behaviours_optimized_extra %} - - {% if history_tasks or running_tasks %} - - {% endif %} - {% if tasks %} - - - - - - - - {% endif %} -{% endblock behaviours_optimized_extra %} -{% block sticky %} -
          - Backup - Restore -
          -{% endblock sticky %} -{% block main %} -

          PBM Restores

          -

          - Restore from PBM MongoDB backups, currently only - supporting Logical, Physical restore methods. -

          - {% if not tasks and not history_tasks %} - {% set modal_required = False %} - {% if executor_hosts|length < 1 %} -

          Unable to find suitable executor hosts.

          - {% else %} - - {% endif %} - {% else %} - {% set modal_required = True %} - {% if running_tasks %} -
          -

          Running

          - {% include "tasks/partials/running-tasks.html.j2" %} -
          - {% endif %} -
          -

          Configured Restores

          - {% include "backup_mongo/restore/partials/saved-tasks.html.j2" %} - {% if executor_hosts|length < 1 %} -

          Unable to find suitable executor hosts.

          - {% else %} - - {% endif %} -

          Periodic Tasks

          - {%- set detail_route = "pbm_restores_detail" %} - {% include "tasks/partials/scheduled-tasks.html.j2" %} -
          -
          -

          Pending

          - {% include "tasks/partials/pending-tasks.html.j2" %} -
          -
          -

          History

          - {% include "tasks/partials/completed-tasks.html.j2" %} -
          - {% endif %} - - {% if executor_hosts|length >= 1 %} - {% include "backup_mongo/restore/partials/create-form.html.j2" %} - {% endif %} -{% endblock main %} diff --git a/templates/backup_mongo/restore/partials/create-form.html.j2 b/templates/backup_mongo/restore/partials/create-form.html.j2 deleted file mode 100644 index 882ae1a6ed..0000000000 --- a/templates/backup_mongo/restore/partials/create-form.html.j2 +++ /dev/null @@ -1,341 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} - -
          - -
          -

          Configure a MongoDB restore task

          -
          -
          - -
          Main Options
          -
          - Executor Host - {% set hostname_title = "Select the hostname of the target system for task execution." %} - - {% if executor_hosts|length == 1 %} - {% set single_host = executor_hosts[0] %} - - - {% else %} - - {% endif %} -
          -
          - Basic Task Information -
          - {% set task_name_title = "Type the name of task to be created." %} - -
          -
          - {% set service_id_title = "Select the database host from the inventory to connect to." %} - -
          -
          - {% set backup_type_title = "Select the type of backup to restore from: Logical or Physical." %} - -
          -
          - {% set backup_source_title = "Enter the backup name or timestamp to restore from (e.g., 2025-12-15T19:04:05Z)." %} - -
          -
          - {% set credentials_path_title = "Optional: path to MongoDB URI credentials file on the Nomad node (e.g. /opt/sep/.mongodb_uri). Stored in restore config and used by the task." %} - -
          -
          - Restore Options (Optional) -
          - {% set restore_batch_size_title = "Number of documents to buffer (default: 500)." %} - -
          -
          - {% set restore_num_insertion_workers_title = "Number of insertion workers to run concurrently per collection (default: 10)." %} - -
          -
          - {% set restore_num_parallel_collections_title = "Number of collections to process in parallel during logical restore (default: CPU cores / 2)." %} - -
          -
          - {% set restore_num_download_workers_title = "Number of workers that request data chunks from storage (default: CPU cores)." %} - -
          -
          - {% set restore_max_download_buffer_mb_title = "Maximum size of in-memory buffer for downloading files from S3 (in MB)." %} - -
          -
          - {% set restore_download_chunk_mb_title = "Size of data chunk in MB to download from S3 storage (default: 32)." %} - -
          -
          - {% set restore_mongod_location_title = "Custom path to mongod binaries." %} - -
          -
          - {% set restore_mongod_location_map_title = "Custom paths to mongod binaries on every node (YAML format, e.g., 'node01:27017: /usr/bin/mongod')." %} - -
          - {% include "tasks/partials/create-form-alert-on-failure-input.html.j2" %} -
          -
          - - -
          -
          -
          - diff --git a/templates/backup_mongo/restore/partials/edit-form.html.j2 b/templates/backup_mongo/restore/partials/edit-form.html.j2 deleted file mode 100644 index f7e87a291a..0000000000 --- a/templates/backup_mongo/restore/partials/edit-form.html.j2 +++ /dev/null @@ -1,19 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% if task.is_edit_enabled %} - {% include "tasks/partials/create-form.html.j2" %} -{% endif %} diff --git a/templates/backup_mongo/restore/partials/saved-tasks.html.j2 b/templates/backup_mongo/restore/partials/saved-tasks.html.j2 deleted file mode 100644 index 99942ac249..0000000000 --- a/templates/backup_mongo/restore/partials/saved-tasks.html.j2 +++ /dev/null @@ -1,64 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% if tasks %} - {# - -
          -

          Debug - Task Data:

          - {% for task in tasks %} -
          - Task {{ loop.index }}: -
          {{ task | tojson(indent=2) }}
          -
          - {% endfor %} -
          - -#} - - - - - - - - - - - - - {% for task in tasks %} - {% if not task.parent %} - - - - - - - - - {% endif %} - {% endfor %} - -
          NameTargetCreated atCreated byLast Updated byActions
          - {{ task.name }} - {{ task.target }}{{ task.created_at }}{{ user_id_to_username.get(task["created_by"], task["created_by"]) or '' }}{{ user_id_to_username.get(task["last_updated_by"], task["last_updated_by"]) or '' }}{% include "tasks/partials/saved-tasks-actions-column.html.j2" %}
          - -{% else %} -

          No data

          -{% endif %} diff --git a/templates/backup_pg/details.html.j2 b/templates/backup_pg/details.html.j2 deleted file mode 100644 index a0028555fb..0000000000 --- a/templates/backup_pg/details.html.j2 +++ /dev/null @@ -1,175 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title -%} - Backups - {{ task.name }} -{%- endblock title %} -{% block style %} - - - - -{% endblock style %} -{% block behaviours_optimized_extra %} - - - {% if history %} - - {% endif %} - -{% endblock behaviours_optimized_extra %} -{% block main %} - - - - - - - - - - - - - - - - - - - - - - - - - -
          Overview
          TargetHostTypeCreated atUpdated at
          {{ task.hostname }}{{ task.host }} ({{ task.port }}){{ task.backup_type }}{{ task.created_at }}{{ task.updated_at }}
          - {% include "tasks/partials/run-python-form.html.j2" %} - {% if running_tasks %} -
          -

          Running

          - {% include "tasks/partials/running-tasks.html.j2" %} -
          - {% endif %} -
          -

          Execution

          -
          Parameters
          - - - {% for k, v in task.meta.items() %} - {% if not k.startswith('_') %} - - - - - {% endif %} - {% endfor %} - -
          {{ k }} - {% if v is mapping %} -
            - {% for j, w in v.items() %}
          • {{ j }}: {{ w }}
          • {% endfor %} -
          - {% elif v is iterable and v is not string %} - {# TODO: Refactor all these weird tests #} -
            - {% for j, w in v[0].items() %}
          • {{ j }}: {{ w }}
          • {% endfor %} -
          - {% else %} - {{ v|syntax_highlight(style="sas") |safe }} - {% endif %} -
          -
          -
          -
          -
          -

          PII Anonymization

          -

          The following sensitive data types are selected for anonymization:

          - {% if task.entities %} - {% for entity, value in task.entities.items() %}{{ entity }}, {% endfor %} - {% else %} -

          No entities selected.

          - {% endif %} -
          -
          -

          Stats

          - {% if stats and stats.total %} - - - - - - - - - - - - - - - - - - - - - - -
          Aggregated statistics for {{ task_name }}
          ExecutionsSucceededFailedLast Finished - Avg Duration (s) - - Last Duration (s) -
          {{ stats.total }}{{ stats.status.pass }}{{ stats.status.fail }}{{ stats.last_finished_at }}{{ stats.duration.average_seconds or 0|round(3) }}{{ stats.duration.last_seconds or 0|round(3) }}
          - {% else %} -

          No data

          - {% endif %} -
          -
          -
          - {% set pending_tasks = history|selectattr("status", "equalto", "pending")|list %} - {% if pending_tasks %} -
          -

          Pending

          - {% include "tasks/partials/pending-tasks.html.j2" %} -
          -
          -
          - {% endif %} -
          -

          History

          - {% set history_tasks = history %} - {% include "tasks/partials/completed-tasks.html.j2" %} -
          -{% endblock main %} diff --git a/templates/backup_pg/index.html.j2 b/templates/backup_pg/index.html.j2 deleted file mode 100644 index 3a1c0d846b..0000000000 --- a/templates/backup_pg/index.html.j2 +++ /dev/null @@ -1,108 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title -%} - PostgreSQL Backups -{%- endblock title %} -{% block style %} - {% if history_tasks or running_tasks %} - - {% endif %} - {% if tasks %} - - - - - {% endif %} -{% endblock style %} -{% block behaviours_optimized_extra %} - - {% if history_tasks or running_tasks %} - - {% endif %} - {% if tasks %} - - - - - - - - {% endif %} -{% endblock behaviours_optimized_extra %} -{% block main %} -

          Perform PostgreSQL backups with PGBackRest.

          - {% if not tasks and not history_tasks %} - {% set modal_required = False %} -

          Configure a backup task

          - {% if services|length < 1 %} -

          Unable to find suitable database instances.

          - {% elif executor_hosts|length < 1 %} -

          Unable to find suitable executor hosts.

          - {% else %} - {% include "backup_pg/partials/create-form.html.j2" %} - {% endif %} - {% else %} - {% set modal_required = True %} - {% if running_tasks %} -
          -

          Running

          - {% include "tasks/partials/running-tasks.html.j2" %} -
          - {% endif %} -
          -

          Saved

          - {% include "backup_pg/partials/saved-tasks.html.j2" %} -

          Periodic Tasks

          - {%- set detail_route = "pg_backups_detail" %} - {% include "tasks/partials/scheduled-tasks.html.j2" %} -

          Create a backup process

          - {% if executor_hosts|length < 1 %} -

          Unable to find suitable executor hosts.

          - {% else %} - {% include "backup_pg/partials/create-form.html.j2" %} - {% endif %} -
          -
          -
          -
          -

          Pending

          - {% include "tasks/partials/pending-tasks.html.j2" %} -
          -
          -
          -
          -

          History

          - {% include "tasks/partials/completed-tasks.html.j2" %} -
          - {% endif %} -{% endblock main %} diff --git a/templates/backup_pg/partials/create-form.html.j2 b/templates/backup_pg/partials/create-form.html.j2 deleted file mode 100644 index cf84264cb1..0000000000 --- a/templates/backup_pg/partials/create-form.html.j2 +++ /dev/null @@ -1,316 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} - -
          - - -
          - Main Options -
          -
          - Executor Host - {% set hostname_title = "Select the hostname of the target system for task execution." %} -
          - -
          -
          -
          - Basic Task Information -
          - {% set task_name_title = "Type the name of task to be created." %} - -
          -
          - {% set service_id_title = "Select the database host from the inventory to connect to." %} -
          - -
          -
          -
          -
          -
          - General Backup Configuration - {% set backup_dir_title = "Type the directory where backup files will be stored." %} - -
          -
          - {% set logging_dir_title = "Type the directory where log files will be stored for backup operations." %} - -
          -
          - {% set compression_algorithm_title = "Select the type of compression algorithm" %} - -
          -
          - PgBackRest Configuration - {% set stanza_title = "The pgBackRest stanza name as defined in pgbackrest.conf on the host (e.g. sep-test). Passed verbatim as --stanza to every pgbackrest invocation." %} - -
          -
          - {% set pgbackrest_bin_title = "The path to the pgbackrest binary." %} - -
          -
          - {% set pgbackrest_config_file_title = "The path to the pgbackrest configuration file." %} - -
          -
          - {% set pgbackrest_backup_type_title = "incr is default, use diff for differential." %} -
          - -
          - {% set pgbackrest_datadir_title = "The path to the PostgreSQL datadir." %} - -
          -
          - {% set pgbackrest_retention_full_title = "The number of full backups to retain." %} - -
          -
          - {% set pgbackrest_retention_archive_title = "The number of archive backups to retain." %} - -
          -
          - {% set pgbackrest_incremental_cycle_title = "The frequency of incremental backups." %} -
          - -
          - {% set pgbackrest_process_max_title = "The maximum number of processes to use for the backup." %} - -
          -
          - {% set pgbackrest_repo_title = "The repository to use for the backup." %} - -
          -
          - {% set pgbackrest_aes256_passfile_title = "The path to the AES-256 encryption key file for securing backups." %} - -
          - {% include "tasks/partials/create-form-alert-on-failure-input.html.j2" %} - {% include "tasks/partials/create-form-connectivity-check-input.html.j2" %} -
          - - -
          -
          - diff --git a/templates/backup_pg/partials/saved-tasks.html.j2 b/templates/backup_pg/partials/saved-tasks.html.j2 deleted file mode 100644 index 6ddf760973..0000000000 --- a/templates/backup_pg/partials/saved-tasks.html.j2 +++ /dev/null @@ -1,46 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% if tasks %} - - - - - - - - - - - - {% for task in tasks %} - - - - - - - - {% endfor %} - -
          NameHostTypeUploadActions
          - {{ task.name }} - {% include "tasks/partials/connectivity-warning-icon.html.j2" %} - {{ task.host }} ({{ task.port }}){{ task.backup_type }}{{ task.upload }}{% include "tasks/partials/saved-tasks-actions-column.html.j2" %}
          - {% include "tasks/partials/scheduled-execution-dialog.html.j2" %} -{% else %} -

          No data

          -{% endif %} diff --git a/templates/base.html.j2 b/templates/base.html.j2 deleted file mode 100644 index f7bcbe7a2b..0000000000 --- a/templates/base.html.j2 +++ /dev/null @@ -1,191 +0,0 @@ - -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} - - - - {# djlint:off #} - {% block title %}New page{% endblock title %} — Services Enablement Platform - {# djlint:on #} - - - - - - {% block meta %} - {% endblock meta %} - - - - - - {% block style %} - {% endblock style %} - {% block behaviours %} - {% endblock behaviours %} - - - - - -
          - {% block home %} - - {% endblock home %} - {% block header %} - - - - {# {% block breadcrumbs %} - -{% endblock breadcrumbs %} #} - {% endblock header %} -
          - - - {% block behaviours_optimized %} - - - - - - - - - {% block behaviours_optimized_extra %} - {% endblock behaviours_optimized_extra %} - {% endblock behaviours_optimized %} - - diff --git a/templates/checksums/details.html.j2 b/templates/checksums/details.html.j2 deleted file mode 100644 index 7fd2d3b254..0000000000 --- a/templates/checksums/details.html.j2 +++ /dev/null @@ -1,184 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title -%} - Table Checksum - {{ task.name }} -{%- endblock title %} -{% block style %} - - - {% if history or running_tasks %} - - {% endif %} - -{% endblock style %} -{% block behaviours_optimized_extra %} - - - {% if history or running_tasks %} - - {% endif %} - -{% endblock behaviours_optimized_extra %} -{% block main %} -

          Overview

          - - - - - - - - - - - - - - - - - - - - - - - - - - -
          HostnameServiceCreated atUpdated atCreated byLast Updated by
          {{ task.hostname }}{{ task.service_name }}{{ task.created_at }}{{ task.updated_at }}{{ user_id_to_username.get(task["created_by"], task["created_by"]) or '' }}{{ user_id_to_username.get(task["last_updated_by"], task["last_updated_by"]) or '' }}
          - {% include "tasks/partials/run-python-form.html.j2" %} - {% include "checksums/partials/edit-form.html.j2" %} - {% if running_tasks %} -
          -

          Running

          - {% include "tasks/partials/running-tasks.html.j2" %} -
          - {% endif %} -
          -

          Execution

          -
          -
          Parameters
          - - - {% for k, v in task.meta.items() %} - {% if not k.startswith('_') %} - - - - - {% endif %} - {% endfor %} - -
          {{ k }} - {% if v is mapping %} -
            - {% for j, w in v.items() %}
          • {{ j }}: {{ w }}
          • {% endfor %} -
          - {% elif v is iterable and v is not string %} - {# TODO: Refactor all these weird tests #} -
            - {% for j, w in v[0].items() %}
          • {{ j }}: {{ w }}
          • {% endfor %} -
          - {% else %} - {{ v }} - {% endif %} -
          -
          -
          -
          -
          -

          PII Anonymization

          -

          The following sensitive data types are selected for anonymization:

          - {% if task.entities %} - {% for entity, value in task.entities.items() %}{{ entity }}, {% endfor %} - {% else %} -

          No entities selected.

          - {% endif %} -
          -
          -

          Stats

          - {% if stats and stats.total %} - - - - - - - - - - - - - - - - - - - - - - -
          Aggregated statistics for {{ task.name }}
          ExecutionsSucceededFailedLast Finished - Avg Duration (s) - - Last Duration (s) -
          {{ stats.total }}{{ stats.status.pass }}{{ stats.status.fail }}{{ stats.last_finished_at }}{{ stats.duration.average_seconds or 0|round(3) }}{{ stats.duration.last_seconds or 0|round(3) }}
          - {% else %} -

          No data

          - {% endif %} -
          -
          -
          - {% set pending_tasks = history|selectattr("status", "equalto", "pending")|list %} - {% if pending_tasks %} -
          -

          Pending

          - {% include "tasks/partials/pending-tasks.html.j2" %} -
          -
          -
          - {% endif %} -
          -

          History

          - {% set history_tasks = history %} - {% include "tasks/partials/completed-tasks.html.j2" %} -
          -{% endblock main %} diff --git a/templates/checksums/index.html.j2 b/templates/checksums/index.html.j2 deleted file mode 100644 index 79abd1e252..0000000000 --- a/templates/checksums/index.html.j2 +++ /dev/null @@ -1,119 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title -%} - Table Checksum -{%- endblock title %} -{% block style %} - - {% if history_tasks or running_tasks %} - - {% endif %} - {% if tasks %} - - - - - {% endif %} -{% endblock style %} -{% block behaviours_optimized_extra %} - - {% if history_tasks or running_tasks %} - - {% endif %} - {% if tasks %} - - - - - - - - {% endif %} -{% endblock behaviours_optimized_extra %} -{% block main %} -

          Table Checksum

          -

          - Perform a checksum, currently only supporting MySQL with pt-table-checksum. -

          - {% if not tasks and not history_tasks %} - {% set modal_required = False %} - {% if services|length < 1 %} -

          Unable to find suitable database instances.

          - {% elif executor_hosts|length < 1 %} -

          Unable to find suitable executor hosts.

          - {% else %} - - {% endif %} - {% else %} - {% set modal_required = True %} - {% if running_tasks %} -
          -

          Running

          - {% include "tasks/partials/running-tasks.html.j2" %} -
          - {% endif %} -
          -

          Saved

          - {% include "checksums/partials/saved-tasks.html.j2" %} -

          Periodic Tasks

          - {%- set detail_route = "checksums_detail" %} - {% include "tasks/partials/scheduled-tasks.html.j2" %} -

          Configure a table checksum

          - {% include "checksums/partials/create-form.html.j2" %} -
          -
          -
          -
          -

          Pending

          - {% include "tasks/partials/pending-tasks.html.j2" %} -
          -
          -
          -
          -

          History

          - {% include "tasks/partials/completed-tasks.html.j2" %} -
          - {% endif %} - - {% if executor_hosts|length >= 1 %} - {% include "checksums/partials/create-form.html.j2" %} - {% endif %} -{% endblock main %} diff --git a/templates/checksums/partials/create-form.html.j2 b/templates/checksums/partials/create-form.html.j2 deleted file mode 100644 index b9f9da1754..0000000000 --- a/templates/checksums/partials/create-form.html.j2 +++ /dev/null @@ -1,394 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} - -
          - -
          -

          Configure a table checksum

          -
          -
          - -
          - Main options -
          - Executor Host -
          - {% set hostname_title = "Select the hostname of the target system for task execution." %} - -
          -
          -
          - Checksum details -
          - {% set task_name_title = "Type the name of task to be created." %} - - -
          -
          - {% set service_id_title = "Select the database host from the inventory to connect to." %} - -
          -
          -
          - Data -
          - {% set databases_title = "Comma-separated database names to checksum." %} - - -
          -
          - {% set tables_title = "Comma-separated tables in schema.table format." %} - - -
          - {% set extra_args_title = "Optional additional pt-table-checksum arguments." %} - -
          -
          -
          -
          -
          - Flags - - - - -
          - -
          - Advanced options -
          - Recursion Method -
          - -
          -
          - - -
          -
          -
          - Customization -
          - - -
          -
          - - -
          -
          - - -
          -
          -
          - Performance -
          - - -
          -
          - - -
          -
          - - -
          -
          -
          - {% include "tasks/partials/create-form-alert-on-failure-input.html.j2" %} - {% include "tasks/partials/create-form-connectivity-check-input.html.j2" %} -
          -
          - - -
          -
          -
          - diff --git a/templates/checksums/partials/edit-form.html.j2 b/templates/checksums/partials/edit-form.html.j2 deleted file mode 100644 index 7df84a0cee..0000000000 --- a/templates/checksums/partials/edit-form.html.j2 +++ /dev/null @@ -1,414 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} - -
          - -
          -

          Edit task

          -
          -
          -

          - You are about to edit the task . -

          -
          -
          - Main options -
          - Executor Host -
          - {% set hostname_title = "Select the hostname of the target system for task execution." %} - -
          -
          -
          - Checksum details -
          - {% set task_name_title = "Type the name of task to be created." %} - - -
          -
          - {% set service_id_title = "Select the database host from the inventory to connect to." %} - -
          -
          -
          - Data -
          - {% set databases_title = "Comma-separated database names to checksum." %} - - -
          -
          - {% set tables_title = "Comma-separated tables in schema.table format." %} - - -
          - {% set extra_args_title = "Optional additional pt-table-checksum arguments." %} - -
          -
          -
          -
          -
          - Flags - - - - -
          -
          - Advanced options -
          - Recursion Method -
          - -
          -
          - - -
          -
          -
          - Customization -
          - - -
          -
          - - -
          -
          - - -
          -
          -
          - Performance -
          - - -
          -
          - - -
          -
          - - -
          -
          -
          - {% include "tasks/partials/create-form-alert-on-failure-input.html.j2" %} -
          -
          -
          - - -
          -
          - -
          - diff --git a/templates/checksums/partials/saved-tasks.html.j2 b/templates/checksums/partials/saved-tasks.html.j2 deleted file mode 100644 index 2a04f7244d..0000000000 --- a/templates/checksums/partials/saved-tasks.html.j2 +++ /dev/null @@ -1,50 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% if tasks %} - - - - - - - - - - - - - {% for task in tasks %} - - - - - - - - - {% endfor %} - -
          NameHostService NameCreated byLast Updated byActions
          - {{ task.name }} - {% include "tasks/partials/connectivity-warning-icon.html.j2" %} - {{ task.hostname }}{{ task.service_name }}{{ user_id_to_username.get(task["created_by"], task["created_by"]) or '' }}{{ user_id_to_username.get(task["last_updated_by"], task["last_updated_by"]) or '' }}{% include "tasks/partials/saved-tasks-actions-column.html.j2" %}
          - {% include "tasks/partials/scheduled-execution-dialog.html.j2" %} -{% else %} -

          No data

          -{% endif %} -
          -
          diff --git a/templates/confirm_modal.html.j2 b/templates/confirm_modal.html.j2 deleted file mode 100644 index 7c7fa62a72..0000000000 --- a/templates/confirm_modal.html.j2 +++ /dev/null @@ -1,166 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} - -
          -
          -

          Are you sure you want to submit?

          - - -
          -
          - diff --git a/templates/dipper/index.html.j2 b/templates/dipper/index.html.j2 deleted file mode 100644 index a266e37637..0000000000 --- a/templates/dipper/index.html.j2 +++ /dev/null @@ -1,203 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title %} - Dipper Data Collection -{% endblock title %} -{% block style %} - - {% if history_tasks or running_tasks %} - - {% endif %} - {% if script_preview %}{% endif %} - -{% endblock style %} -{% block behaviours_optimized_extra %} - - - {% if history_tasks or running_tasks %} - - {% endif %} - -{% endblock behaviours_optimized_extra %} -{% block main %} -
          -

          Dipper Data Collection

          -

          Execute Percona-provided data collection scripts on the database host and download the generated artifacts.

          -
          -
          - Service -
          -
          - - -
          - {% if is_pmm_script_available %} -
          - - -
          - {% endif %} -
          -
          -
          - {% if selected_service and script %} -
          -

          {{ script.title }}

          - {% if script.description %}

          {{ script.description }}

          {% endif %} -
          -
          Service
          -
          -
          {{ selected_service.name }} ({{ selected_service.type.value }})
          -
          -
          Node
          -
          -
          {{ selected_service.node.name }} / {{ selected_service.node.address }}
          -
          - {%- if resolved_executor_host is defined and resolved_executor_host %} -
          Executor Host
          -
          -
          {{ resolved_executor_host }}
          -
          - {%- endif %} -
          Script
          -
          -
          {{ script.filename }}
          -
          -
          -
          -
          -

          Preview

          - {% if script_preview %} - {{ script_preview | safe }} - {% else %} -

          No preview available.

          - {% endif %} -
          -
          -

          Execute

          - {{ script.to_form(executor_hosts, script_execution_url, form_defaults) | safe }} - -
          - {% if running_tasks %} -
          -

          Running

          - {% include "tasks/partials/running-tasks.html.j2" %} -
          - {% endif %} - {% if history_tasks %} -
          -

          History

          - {% include "tasks/partials/completed-tasks.html.j2" %} -
          - {% endif %} - {% endif %} -{% endblock main %} diff --git a/templates/error.html.j2 b/templates/error.html.j2 deleted file mode 100644 index 10a84fbe20..0000000000 --- a/templates/error.html.j2 +++ /dev/null @@ -1,27 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title -%} - Uh oh! We've found a problem -{%- endblock title %} -{% block main %} -

          Error

          - {% if user.is_admin %} -

          Traceback

          -
          {{ exception }}
          - {% endif %} -{% endblock main %} diff --git a/templates/homepage.html.j2 b/templates/homepage.html.j2 deleted file mode 100644 index b0281c8d78..0000000000 --- a/templates/homepage.html.j2 +++ /dev/null @@ -1,57 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title %} - Home Dashboard -{% endblock title %} -{% block main %} - - {% if history or running_tasks %} - - {% endif %} - {% if periodic_tasks %} - - - - {% endif %} -
          -

          Running

          - {% include "homepage/running-tasks.html.j2" %} -
          -
          -

          Pending

          - {% include "homepage/pending-tasks.html.j2" %} -
          -
          -

          Periodic Tasks

          - {% include "homepage/scheduled-tasks.html.j2" %} -
          - {% include "inventory/partials/inventory-metrics.html.j2" %} - {% include "tasks/partials/host-metrics.html.j2" %} -{% endblock main %} -{% block behaviours_optimized_extra %} - - {% if history_tasks or running_tasks %} - - {% endif %} -{% endblock behaviours_optimized_extra %} diff --git a/templates/homepage/pending-tasks.html.j2 b/templates/homepage/pending-tasks.html.j2 deleted file mode 100644 index 56851b85af..0000000000 --- a/templates/homepage/pending-tasks.html.j2 +++ /dev/null @@ -1,58 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -
          - - - - - - - - - - - - {% if pending_tasks %} - {% for task in pending_tasks %} - {% set task_name = task["task"]["name"] %} - - - - - {% set eta = task['execution_request'].get('eta') %} - - - - {% endfor %} - {% else %} - - - - {% endif %} - -
          NameTargetCreated AtETAActions
          {{ task_name }}{{ task['execution_request']['target'] }}{{ task['created_at'] }}{{ eta }} - {% if is_task_manager_enabled %} - visibility - {% else %} - - visibility_off - Enable the Task Manager plugin or navigate to the "{{ task["task"]["owner"] }}" - plugin to view details of this task. - - {% endif %} -
          No tasks pending
          -
          diff --git a/templates/homepage/running-tasks.html.j2 b/templates/homepage/running-tasks.html.j2 deleted file mode 100644 index 322baaf0ca..0000000000 --- a/templates/homepage/running-tasks.html.j2 +++ /dev/null @@ -1,55 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -
          - - - - - - - - - - - {% if running_tasks %} - {% for task in running_tasks %} - {% set task_name = task["task"]["name"] %} - - - - - - - {% endfor %} - {% else %} - - - - {% endif %} - -
          NameTargetStart AtActions
          {{ task_name }}{{ task['execution_request']['target'] }}{{ task['updated_at'] }} - {% if is_task_manager_enabled %} - visibility - {% else %} - - visibility_off - Enable the Task Manager plugin or navigate to the "{{ task["task"]["owner"] }}" - plugin to view details of this task. - - {% endif %} -
          No tasks running
          -
          diff --git a/templates/homepage/scheduled-tasks.html.j2 b/templates/homepage/scheduled-tasks.html.j2 deleted file mode 100644 index 340d12034b..0000000000 --- a/templates/homepage/scheduled-tasks.html.j2 +++ /dev/null @@ -1,86 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -
          - - - - - - - - - - - - - - - - {% if periodic_tasks %} - {% for periodic_task in periodic_tasks %} - {% set task_name = periodic_task.task %} - - - - {% if periodic_task.crontab %} - {# djlint:off H025 #} - - - - - - - - {% endfor %} - {% else %} - - - - {% endif %} - -
          TaskOwnerPeriodLast Run AtNext ExecutionRunsChain withActions
          {{ task_name }}{{ periodic_task.owner }} - {# djlint:on #} - {% else %} - - {% endif %} - {{ periodic_task.period }} - {{ periodic_task.last_run_at or '' }}{{ periodic_task.next_run_at or '' }}{{ periodic_task.total_run_count }} - {% if periodic_task.execute_request and periodic_task.execute_request.chain_task_names %} - {% with chain_names=periodic_task.execute_request.chain_task_names %} - {% include "tasks/partials/chain-display.html" %} - {% endwith %} - {% else %} - — - {% endif %} - - {% if is_task_manager_enabled %} - visibility - {% else %} - - visibility_off - Enable the Task Manager plugin or navigate to the "{{ periodic_task.owner }}" - plugin to view details of this task. - - {% endif %} -
          No periodic tasks
          -
          diff --git a/templates/inventory/base-inventory.html.j2 b/templates/inventory/base-inventory.html.j2 deleted file mode 100644 index 16be47fc41..0000000000 --- a/templates/inventory/base-inventory.html.j2 +++ /dev/null @@ -1,54 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block style %} - -{% endblock style %} -{% block behaviours %} - {% if sync_is_running %} - - {% endif %} -{% endblock behaviours %} -{% block main %} - {% include "inventory/partials/breadcrumb.html.j2" %} -
          - {% block inventory_entity_details %} - {% endblock inventory_entity_details %} -
          - {% block inventory_table %} - {% endblock inventory_table %} -
          -{% endblock main %} diff --git a/templates/inventory/node-detail.html.j2 b/templates/inventory/node-detail.html.j2 deleted file mode 100644 index 0acf492a3f..0000000000 --- a/templates/inventory/node-detail.html.j2 +++ /dev/null @@ -1,98 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "inventory/base-inventory.html.j2" %} -{% block title -%} - Node {{ node.name }} -{%- endblock title %} -{% block inventory_entity_details %} - {% with inventory_table_title = "Node Details", inventory_sync_route = "/inventory/" ~ node.id ~ "/sync/" %} - {% include "inventory/partials/sync-button.html.j2" %} - {% endwith %} -
          -
          Name
          -
          - {{ node.name }} -
          -
          Address
          -
          - {{ node.address }} -
          -
          Type
          -
          - {{ node.type }} -
          - {% if node.source %} -
          Source
          -
          - {{ node.source }} -
          - {% if node.external_id %} -
          External ID
          -
          - {{ node.external_id }} -
          - {% endif %} - {% endif %} -
          -
          -{% endblock inventory_entity_details %} -{% block inventory_table %} -

          Services on this Node

          - - - - - - - - - - - - - - {% for service in node.services %} - - - - - - - - - - {% endfor %} - -
          NameTypePortEnvironmentClusterReplication SetActions
          - {{ service.name }} - {{ service.type }}{{ service.port }}{{ service.environment or '-' }}{{ service.cluster or '-' }}{{ service.replication_set or '-' }} -
          - - -
          -
          -{% endblock inventory_table %} -{% block inventory_create %} -

          Add a New Service

          - {% include "inventory/partials/service-create-form.html.j2" %} -{% endblock inventory_create %} diff --git a/templates/inventory/node-list.html.j2 b/templates/inventory/node-list.html.j2 deleted file mode 100644 index b4799a3e1a..0000000000 --- a/templates/inventory/node-list.html.j2 +++ /dev/null @@ -1,75 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "inventory/base-inventory.html.j2" %} -{% block title -%} - Node List -{%- endblock title %} -{% block inventory_table %} - {% with inventory_table_title = "Node List", inventory_sync_route = "/inventory/sync/" %} - {% include "inventory/partials/sync-button.html.j2" %} - {% endwith %} - {% include "inventory/partials/sync-schedule.html.j2" %} -
          - - - - - - - - - - - - {% for node in inventory %} - - - - - - - - {% endfor %} - -
          NameAddressTypeSourceActions
          - {{ node.name }} - {{ node.address }}{{ node.type }} - {% if node.source %} - {{ node.source }} - {% if node.external_id %} - ( - {{ node.external_id }}) - {% endif %} - {% endif %} - -
          - {# TODO: Fix button clickable size #} - - -
          -
          -{% endblock inventory_table %} -{% block inventory_create %} -

          Add a New Node

          - {% include "inventory/partials/node-create-form.html.j2" %} -{% endblock inventory_create %} diff --git a/templates/inventory/partials/breadcrumb.html.j2 b/templates/inventory/partials/breadcrumb.html.j2 deleted file mode 100644 index cafdf7a66c..0000000000 --- a/templates/inventory/partials/breadcrumb.html.j2 +++ /dev/null @@ -1,93 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} - -{% if schema is defined or service is defined or node is defined %} - -{% else %} - -{% endif %} diff --git a/templates/inventory/partials/connectivity-check-button.html.j2 b/templates/inventory/partials/connectivity-check-button.html.j2 deleted file mode 100644 index 5e24d9cfd1..0000000000 --- a/templates/inventory/partials/connectivity-check-button.html.j2 +++ /dev/null @@ -1,26 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% if can_check_connectivity %} -
          - - -
          -{% endif %} diff --git a/templates/inventory/partials/inventory-metrics.html.j2 b/templates/inventory/partials/inventory-metrics.html.j2 deleted file mode 100644 index 451727c406..0000000000 --- a/templates/inventory/partials/inventory-metrics.html.j2 +++ /dev/null @@ -1,37 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -
          -

          Inventory

          -
          - - - - - - - - - - - - - - - -
          NodesServicesSchemasTables
          {{ nodes }}{{ services }}{{ schemas }}{{ tables }}
          -
          -
          diff --git a/templates/inventory/partials/node-create-form.html.j2 b/templates/inventory/partials/node-create-form.html.j2 deleted file mode 100644 index 066730faab..0000000000 --- a/templates/inventory/partials/node-create-form.html.j2 +++ /dev/null @@ -1,66 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -
          - -
          - Create New Node -
          - - -
          -
          - - -
          -
          -
          - -
          -
          - - -
          -
          - - -
          -
          -
          -
          - -
          - diff --git a/templates/inventory/partials/schema-create-form.html.j2 b/templates/inventory/partials/schema-create-form.html.j2 deleted file mode 100644 index 3234c416c1..0000000000 --- a/templates/inventory/partials/schema-create-form.html.j2 +++ /dev/null @@ -1,30 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -
          - -
          - Create New Schema -
          - - -
          -
          -
          -
          - -
          diff --git a/templates/inventory/partials/service-create-form.html.j2 b/templates/inventory/partials/service-create-form.html.j2 deleted file mode 100644 index e4c4aa903f..0000000000 --- a/templates/inventory/partials/service-create-form.html.j2 +++ /dev/null @@ -1,57 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -
          - -
          - Create New Service -
          - - -
          -
          -
          - -
          -
          - {% if node.source %} - - - {% endif %} -
          -
          - - -
          -
          - - -
          -
          -
          -
          - -
          diff --git a/templates/inventory/partials/sync-button.html.j2 b/templates/inventory/partials/sync-button.html.j2 deleted file mode 100644 index fbbedfb966..0000000000 --- a/templates/inventory/partials/sync-button.html.j2 +++ /dev/null @@ -1,64 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -
          -

          {{ inventory_table_title }}

          - {% if can_sync %} - - {% endif %} -
          diff --git a/templates/inventory/partials/sync-schedule-edit-form.html.j2 b/templates/inventory/partials/sync-schedule-edit-form.html.j2 deleted file mode 100644 index 09a9afa089..0000000000 --- a/templates/inventory/partials/sync-schedule-edit-form.html.j2 +++ /dev/null @@ -1,120 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% set is_create = sync_schedule is none %} -{% set has_interval = sync_schedule and sync_schedule.interval %} -{% set has_crontab = sync_schedule and sync_schedule.crontab %} -{% set default_interval_mode = is_create or has_interval %} -{# Suffix every id/for inside this partial so the page stays valid HTML when - it is included multiple times (one per existing schedule row plus the - attach form). #} -{% set form_id = 'create' if is_create else sync_schedule.id %} -
          - - {# Editing the syncer in place is disallowed: the syncer is row identity. - To target a different syncer, clear the row and attach a new one. #} - {% if is_create %} -
          - Syncer - - {% for available in available_syncers %} - - {% endfor %} -
          - {% endif %} -
          - - {% if is_create %} - Attach - {% else %} - Edit - {% endif %} - Schedule - - - -
          - - - -
          -
          - - - - -
          -
          - -
          diff --git a/templates/inventory/partials/sync-schedule.html.j2 b/templates/inventory/partials/sync-schedule.html.j2 deleted file mode 100644 index c55b9d2fa1..0000000000 --- a/templates/inventory/partials/sync-schedule.html.j2 +++ /dev/null @@ -1,186 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -
          -

          Inventory Sync Schedule

          - {% if sync_schedules %} -
            - {% for sync_schedule in sync_schedules %} - {% set syncer_qualified = (sync_schedule.execute_request.meta or {}).get('syncer') if sync_schedule.execute_request else None %} - {% set ns = namespace(syncer_label='All syncers') %} - {% if syncer_qualified %} - {% set ns.syncer_label = syncer_qualified %} - {% for available in available_syncers %} - {% if available.name == syncer_qualified %} - {% set ns.syncer_label = available.display_name %} - {% endif %} - {% endfor %} - {% endif %} -
          • -

            - {{ ns.syncer_label }} - — - {% if sync_schedule.crontab %} - - Cron: {{ sync_schedule.period }} - ({{ sync_schedule.crontab.timezone }}) - - {% else %} - {{ sync_schedule.period }} - {% endif %} - {% if not sync_schedule.enabled %}(disabled){% endif %} - {% if sync_schedule.start_time %} - · Starts at: {{ sync_schedule.start_time }} - {% endif %} - {% if sync_schedule.next_run_at %} - · Next run: {{ sync_schedule.next_run_at }} - {% endif %} - {% if sync_schedule.last_run_at %} - · Last run: {{ sync_schedule.last_run_at }} - {% endif %} - {% if not scheduled_sync_enabled %} - (will not run — SEP_INTERNAL_TOKEN missing) - {% endif %} -

            -
            - -
            - - - -
            -
            - - -
            -
            - -
          • - {% endfor %} -
          - {% else %} -

          No schedule configured.

          - {% endif %} - {% if scheduled_sync_enabled %} -
          - Attach a schedule -
          - {% with sync_schedule = none %} - {% include "inventory/partials/sync-schedule-edit-form.html.j2" %} - {% endwith %} -
          -
          - {% else %} -

          - Scheduled inventory sync is currently unavailable. Set - SEP_INTERNAL_TOKEN in the settings to enable. -

          - {% endif %} -
          - - diff --git a/templates/inventory/partials/table-create-form.html.j2 b/templates/inventory/partials/table-create-form.html.j2 deleted file mode 100644 index 630941945f..0000000000 --- a/templates/inventory/partials/table-create-form.html.j2 +++ /dev/null @@ -1,28 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -
          - -
          - Create New Table - - - - -
          - -
          diff --git a/templates/inventory/schema-detail.html.j2 b/templates/inventory/schema-detail.html.j2 deleted file mode 100644 index 9e56ef6afd..0000000000 --- a/templates/inventory/schema-detail.html.j2 +++ /dev/null @@ -1,138 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "inventory/base-inventory.html.j2" %} -{% block title -%} - Schema {{ schema.name }} ({{ schema.service_id }}) -{%- endblock title %} -{% block style %} - {{ super() }} - -{% endblock style %} -{% block inventory_entity_details %} - {% with inventory_table_title = "Schema Details", inventory_sync_route = "/inventory/schemas/" ~ schema.id ~ "/sync/" %} - {% include "inventory/partials/sync-button.html.j2" %} - {% endwith %} -
          -
          Name
          -
          - {{ schema.name }} -
          -
          -{% endblock inventory_entity_details %} -
          -{% block inventory_table %} -

          Tables in this Schema

          - - - - - - - - - - - {% for table in schema.tables %} - - - - - - - {% endfor %} - -
          NameCreate StatementKeysActions
          {{ table.name }} -
          {{ table.create|syntax_highlight("sql", style="sas") |safe }}
          - -
          - {% if table.keys %} -
          - {{ table.keys|tojson(2) |syntax_highlight("json", style="sas")|safe }} -
          - - {% endif %} -
          -
          - - -
          -
          - -{% endblock inventory_table %} -{% block inventory_create %} -

          Add a New Table

          - {% include "inventory/partials/table-create-form.html.j2" %} -{% endblock inventory_create %} diff --git a/templates/inventory/service-detail.html.j2 b/templates/inventory/service-detail.html.j2 deleted file mode 100644 index cd2f32f2d1..0000000000 --- a/templates/inventory/service-detail.html.j2 +++ /dev/null @@ -1,111 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "inventory/base-inventory.html.j2" %} -{% block title -%} - Service {{ service.name }} ({{ service.node_id }}) -{%- endblock title %} -{% block inventory_entity_details %} - {% with inventory_table_title = "Service Details", inventory_sync_route = "/inventory/services/" ~ service.id ~ "/sync/" %} - {% include "inventory/partials/sync-button.html.j2" %} - {% endwith %} - {% include "inventory/partials/connectivity-check-button.html.j2" %} -
          -
          Name
          -
          - {{ service.name }} -
          -
          Type
          -
          - {{ service.type }} -
          - {% if service.node.source %} -
          Source
          -
          - {{ service.node.source }} -
          - {% endif %} - {% if service.external_id %} -
          External ID
          -
          - {{ service.external_id }} -
          - {% endif %} -
          Port
          -
          - {{ service.port }} -
          - {% if service.environment %} -
          Environment
          -
          - {{ service.environment }} -
          - {% endif %} - {% if service.cluster %} -
          Cluster
          -
          - {{ service.cluster }} -
          - {% endif %} - {% if service.replication_set %} -
          Replication Set
          -
          - {{ service.replication_set }} -
          - {% endif %} - {% if service.custom_labels %} -
          Custom Labels
          -
          - {% for key, value in service.custom_labels.items() %}{{ key }}: {{ value }}{% endfor %} -
          - {% endif %} -
          -
          -{% endblock inventory_entity_details %} -{% block inventory_table %} -

          Schemas in this Service

          - - - - - - - - - {% for schema in service.schemas %} - - - - - {% endfor %} - -
          NameActions
          - {{ schema.name }} - -
          - - -
          -
          -{% endblock inventory_table %} -{% block inventory_create %} -

          Add a New Schema

          - {% include "inventory/partials/schema-create-form.html.j2" %} -{% endblock inventory_create %} diff --git a/templates/login.html.j2 b/templates/login.html.j2 deleted file mode 100644 index 6a9d9dbf96..0000000000 --- a/templates/login.html.j2 +++ /dev/null @@ -1,42 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title -%} - Login -{%- endblock title %} -{% block sidebar %} -{% endblock sidebar %} -{% block main %} - -
          -

          Login

          -
          -
          - Credentials - - -
          -
          - - - -
          - -
          -
          -{% endblock main %} diff --git a/templates/menus/partials/sidebar.html.j2 b/templates/menus/partials/sidebar.html.j2 deleted file mode 100644 index 676d6c042e..0000000000 --- a/templates/menus/partials/sidebar.html.j2 +++ /dev/null @@ -1,71 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% block sidebar %} - -{% endblock sidebar %} diff --git a/templates/mysql_backups/backup/details.html.j2 b/templates/mysql_backups/backup/details.html.j2 deleted file mode 100644 index 32b3e4392a..0000000000 --- a/templates/mysql_backups/backup/details.html.j2 +++ /dev/null @@ -1,201 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title -%} - Backups - {{ task.name }} -{%- endblock title %} -{% block style %} - - - - - -{% endblock style %} -{% block behaviours_optimized_extra %} - - - {% if history %} - - {% endif %} - -{% endblock behaviours_optimized_extra %} -{% block sticky %} -
          - Backup - Restore -
          -{% endblock sticky %} -{% block main %} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
          Overview
          TargetHostTypeCreated atUpdated atCreated byLast Updated by
          {{ task.hostname }}{{ task.host }}{{ task.backup_type }}{{ task.created_at }}{{ task.updated_at }}{{ user_id_to_username.get(task["created_by"], task["created_by"]) or '' }}{{ user_id_to_username.get(task["last_updated_by"], task["last_updated_by"]) or '' }}
          - {% include "tasks/partials/run-python-form.html.j2" %} - {% if running_tasks %} -
          -

          Running

          - {% include "tasks/partials/running-tasks.html.j2" %} -
          - {% endif %} -
          -

          Execution

          -
          Parameters
          - - - {% for k, v in task.meta.items() %} - {% if not k.startswith('_') %} - - - - - {% endif %} - {% endfor %} - -
          {{ k }} - {% if v is mapping %} -
            - {% for j, w in v.items() %}
          • {{ j }}: {{ w }}
          • {% endfor %} -
          - {% elif v is iterable and v is not string %} - {# TODO: Refactor all these weird tests #} -
            - {% for j, w in v[0].items() %}
          • {{ j }}: {{ w }}
          • {% endfor %} -
          - {% else %} - {{ v|syntax_highlight(style="sas") |safe }} - {% endif %} -
          -
          -
          -
          -
          -

          PII Anonymization

          -

          The following sensitive data types are selected for anonymization:

          - {% if task.entities %} - {% for entity, value in task.entities.items() %}{{ entity }}, {% endfor %} - {% else %} -

          No entities selected.

          - {% endif %} -
          -
          -

          Stats

          - {% if stats and stats.total %} - - - - - - - - - - - - - - - - - - - - - - -
          Aggregated statistics for {{ task_name }}
          ExecutionsSucceededFailedLast Finished - Avg Duration (s) - - Last Duration (s) -
          {{ stats.total }}{{ stats.status.pass }}{{ stats.status.fail }}{{ stats.last_finished_at }}{{ stats.duration.average_seconds or 0|round(3) }}{{ stats.duration.last_seconds or 0|round(3) }}
          - {% else %} -

          No data

          - {% endif %} -
          -
          -
          - {% set pending_tasks = history|selectattr("status", "equalto", "pending")|list %} - {% if pending_tasks %} -
          -

          Pending

          - {% include "tasks/partials/pending-tasks.html.j2" %} -
          -
          -
          - {% endif %} -
          -

          History

          - {% set history_tasks = history %} - {% include "tasks/partials/completed-tasks.html.j2" %} -
          - {% include "mysql_backups/backup/partials/edit-form.html.j2" %} -{% endblock main %} diff --git a/templates/mysql_backups/backup/docs.html.j2 b/templates/mysql_backups/backup/docs.html.j2 deleted file mode 100644 index 61e44fbb24..0000000000 --- a/templates/mysql_backups/backup/docs.html.j2 +++ /dev/null @@ -1,92 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title -%} - MySQL Backup Guide -{%- endblock title %} -{% block style %} - -{% endblock style %} -{% block main %} - -
          -
          -
          -

          MySQL Backup Guide

          -

          Use this reference to plan and configure reliable backup jobs for MySQL and MariaDB.

          -
          - -
          -
          -

          Prerequisites

          -
            -
          • - Executor host must provide MyDumper ≥ 0.17.1.1, XtraBackup, or mariadb-backup binaries, plus required CLI tools (rsync, aws-cli, gpg) in $PATH -
          • -
              -
            • - When using XtraBackup, remote database hosts should have necessary Python packages available on the system: PyYAML, PyMySQL[rsa,ed25519], packaging, filelock, boto3. These packages are installed by SEP within a Python virtual environment on the database host -
            • -
            -
          • - Database user grants require SELECT, RELOAD, PROCESS, LOCK TABLES, REPLICATION CLIENT, plus access to replica status queries -
          • -
          • - Executor host must be able to reach database host(s) on SSH and MySQL ports; plus any remote storage (S3/rsync) locations for backup storage -
          • -
          -
          -
          -

          Workflow

          -
            -
          • Select an executor host and an inventory service for your MySQL/MariaDB database node
          • -
          • Enter a unique task name for the backup task
          • -
          • Pick a backup type:
          • -
              -
            • - M (MyDumper) for logical dumps -
            • -
            • - X (XtraBackup) for physical copies -
            • -
            • - B (Binlog) for continuous binary logs -
            • -
            -
          • Fill in generation configuration options (directories, compression, encryption, safety checks)
          • -
          • Fill in the backup type options
          • -
          • Add uploading and/or alerting options; optional and up to the user's discretion
          • -
          -
          -
          -{% endblock main %} diff --git a/templates/mysql_backups/backup/index.html.j2 b/templates/mysql_backups/backup/index.html.j2 deleted file mode 100644 index d36b85f261..0000000000 --- a/templates/mysql_backups/backup/index.html.j2 +++ /dev/null @@ -1,149 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title -%} - Backups -{%- endblock title %} -{% block style %} - - {% if history_tasks or running_tasks %} - - {% endif %} - {% if tasks %} - - - - - {% endif %} -{% endblock style %} -{% block behaviours_optimized_extra %} - - {% if history_tasks or running_tasks %} - - {% endif %} - {% if tasks %} - - - - - - - - {% endif %} -{% endblock behaviours_optimized_extra %} -{% block sticky %} -
          - Backup - Restore -
          -{% endblock sticky %} -{% block main %} - -

          - Perform a backup, currently only supporting MySQL and MariaDB with Percona XtraBackup - (physical) and - MyDumper (logical). -

          -
          - -
          - New to the workflow? Read the MySQL backup guide for prerequisites, configuration tips, and restore planning. -
          -
          - {% if not tasks and not history_tasks %} - {% set modal_required = False %} - {% if services|length < 1 %} -

          Unable to find suitable database instances.

          - {% elif executor_hosts|length < 1 %} -

          Unable to find suitable executor hosts.

          - {% else %} - - {% endif %} - {% else %} - {% set modal_required = True %} - {% if running_tasks %} -
          -

          Running

          - {% include "tasks/partials/running-tasks.html.j2" %} -
          - {% endif %} -
          -

          Saved

          - {% include "mysql_backups/backup/partials/saved-tasks.html.j2" %} - {% if executor_hosts|length < 1 %} -

          Unable to find suitable executor hosts.

          - {% else %} - - {% endif %} -

          Periodic Tasks

          - {%- set detail_route = "mysql_backups_detail" %} - {% include "tasks/partials/scheduled-tasks.html.j2" %} -
          -
          -
          -
          -

          Pending

          - {% include "tasks/partials/pending-tasks.html.j2" %} -
          -
          -
          -
          -

          History

          - {% include "tasks/partials/completed-tasks.html.j2" %} -
          - {% endif %} - - {% if executor_hosts|length >= 1 %} - {% include "mysql_backups/backup/partials/create-form.html.j2" %} - {% endif %} -{% endblock main %} diff --git a/templates/mysql_backups/backup/partials/create-form.html.j2 b/templates/mysql_backups/backup/partials/create-form.html.j2 deleted file mode 100644 index 47bec0f18f..0000000000 --- a/templates/mysql_backups/backup/partials/create-form.html.j2 +++ /dev/null @@ -1,1154 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} - -{% from "mysql_backups/backup/partials/info-icon.html.j2" import info_icon %} -
          - -
          -

          Configure a backup task

          -
          -
          - -
          - -
          - Need a step-by-step checklist? Open the backup documentation in a new tab for detailed guidance. -
          -
          -
          - Main Options -
          -
          - Executor Host - {% set hostname_title = "Select the hostname of the target system for task execution." %} -
          - -
          -
          -
          - Basic Task Information -
          - {% set task_name_title = "Type the name of task to be created." %} - -
          -
          - {% set service_id_title = "Select the database host from the inventory to connect to." %} -
          - -
          -
          - {% set backup_type_title = "Select the type of backup: MyDumper or XtraBackup." %} -
          - -
          -
          -
          - {% set encryption_recipient_title = "GPG recipient/key the backup is encrypted for. Required when either encryption mode is enabled." %} - -
          -
          -
          -
          -
          - General Backup Configuration -
          - {% set hardlink_title = "Enable hardlinks to save storage space by linking identical files instead of duplicating them." %} - - {% set compress_title = "Enable compression to reduce backup file size and save storage space." %} - - {% set check_disk_space_title = "Verify available disk space before starting the backup to prevent failures." %} - - {% set encrypt_title = "GPG-encrypt the backup in place. Combine with 'Encrypt using tmpdir', or use 'Encrypt after backup completes' for post-run encryption instead. Either encryption option needs a recipient." %} - - {% set post_run_encrypt_title = "GPG-encrypt the finished backup once it completes. Independent of 'Encrypt backup'; mutually exclusive with 'Encrypt using tmpdir'. Needs an encryption recipient." %} - - {% set encrypt_using_tmpdir_title = "Encrypt in a temporary directory during the backup. Requires 'Encrypt backup'; mutually exclusive with 'Encrypt after backup completes'." %} - - {% set only_if_running_replica_title = "Enable this option to apply the backup only if the server is running as a replication slave." %} - - {% set only_if_read_only_title = "Enable this option to perform the backup only if the database is in read-only mode." %} - -
          -
          - {% set backup_dir_title = "Type the directory where backup files will be stored." %} - -
          -
          - {% set logging_dir_title = "Type the directory where log files will be stored for backup operations." %} - -
          -
          - {% set alias_title = "Optional alias for the server in the SERVERS_LIST section. Defaults to the service IP if not provided." %} - -
          -
          - {% set defaults_file_title = "the path to the defaults file containing preset configuration values for the backup process." %} - -
          -
          - {% set compression_algorithm_title = "Select the type of compression algorithm" %} - -
          -
          - Mydumper Configuration - {% set mydumper_daily_purge_title = "Set the number of days after which old backups will be automatically deleted." %} - - {% set mydumper_weekly_purge_title = "Set the number of weeks after which old backups will be automatically deleted on a weekly basis." %} - -
          - {% set mydumper_dump_triggers_title = "Include database triggers in the backup to preserve trigger functionality." %} - - {% set mydumper_desync_pxc_title = "Temporarily desynchronize the Percona XtraDB Cluster (PXC) node during backup to reduce replication lag." %} - - {% set mydumper_use_numa_title = "Enable NUMA (Non-Uniform Memory Access) awareness to optimize memory allocation for backup processes." %} - - {% set use_ftwrl_guardian_title = "Enable FTWRL (Flush Tables with Read Lock) Guardian to manage table locks and prevent long lock times during backup." %} - -
          - {% set mydumper_extra_args_title = "Type additional command-line arguments for the backup process to customize its behavior." %} - -
          -
          - Xtrabackup Configuration - {% set xtrabackup_copies_title = "Set the number of backup copies to retain for redundancy and recovery." %} - -
          - {% set xtrabackup_kill_queries_title = "Terminate long-running queries that may block the XtraBackup process to ensure a smooth backup." %} - - {% set xtrabackup_verify_title = "Enable verification of the XtraBackup process to ensure data integrity and consistency." %} - - {% set xtrabackup_prepare_title = "Prepare the XtraBackup files for restoration by applying redo logs and ensuring consistency." %} - - {% set xtrabackup_desync_pxc_title = "Desynchronize the Percona XtraDB Cluster (PXC) node during XtraBackup to minimize replication impact." %} - - {% set xtrabackup_rsync_title = "Use rsync to optimize file transfers during XtraBackup, reducing disk I/O and improving performance." %} - - {% set xtrabackup_replica_info_title = "Include replica information in the backup to assist with replication recovery." %} - - {% set xtrabackup_stop_replica_title = "Stop replication before starting the backup to ensure data consistency." %} - - {% set xtrabackup_lock_ddl_title = "Lock Data Definition Language (DDL) operations during backup to prevent schema changes." %} - -
          - {% set xtrabackup_kill_queries_timeout_title = "Set the maximum time (in seconds) to wait before forcibly terminating long-running queries that block the backup process." %} - - {% set xtrabackup_kill_query_type_title = "Choose which type of queries should be terminated to prevent backup interruptions." %} -
          - -
          - {% set xtrabackup_prepare_memory_title = "Set the amount of memory to allocate for the prepare phase of XtraBackup. Leave empty for default." %} - - {% set xtrabackup_defaults_file_title = "Type the path to the optional defaults file for predefined configuration settings." %} - - {% set xtrabackup_extra_args_title = "Type additional command-line arguments for XtraBackup to customize its behavior." %} - - {% set xtrabackup_incremental_method_title = "Choose the incremental backup method: 'less_space' (reduces storage usage) or 'fast_restore' (accelerates restoration)." %} -
          - -
          - {% set xtrabackup_incremental_cycle_title = "Select the frequency for incremental backups: 'daily' for daily backups or 'weekly' for weekly backups." %} -
          - -
          - {% set xtrabackup_local_ssh_destination_title = "Type the SSH destination for storing backups remotely. Format: user@host:/path" %} - - {% set xtrabackup_aes256_keyfile_title = "Type the path to the AES-256 encryption key file for securing backups." %} - - {% set xtrabackup_bin_cmd_title = "Select the backup tool to use: 'xtrabackup' (default for Percona), 'mariadb-backup' (MariaDB compatibility), or 'innobackupex' (legacy tool)." %} -
          - -
          -
          -
          - Binlog Configuration - {% set binlog_alternative_host_title = "Optional alternative host address for pulling binlogs when different from the main database host." %} - - {% set binlog_prefix_title = "Prefix used for binlog file naming. Default is mysql-bin." %} - - {% set binlog_purge_days_title = "Number of days to retain binlogs before automatically purging old files." %} - - {% set binlog_extra_args_title = "Additional command-line arguments to pass to the mysqlbinlog backup process." %} - - {% set binlog_compress_cmd_title = "Command used to compress binlog backup files after they are written." %} - - {% set binlog_cmd_title = "Path to the mysqlbinlog binary used for creating binlog backups." %} - -
          -
          - Additional Upload Options - - - - -
          -
          - {% set s3_bucket_title = "Type the name of the S3 bucket where backups will be stored." %} - -
          -
          - {% set s3_storage_class_title = "Type the S3 storage class for backups." %} - -
          -
          - {% set awscli_s3_upload_extra_args_title = "Additional ExtraArgs to pass to AWS S3 upload (e.g. ChecksumAlgorithm=CRC32C)" %} - -
          -
          - {% set skip_s3_safety_check_title = "Disable safety checks before uploading backups to S3. Use with caution to avoid accidental data loss." %} - -
          - -
          - - -
          - {% set rsync_path_title = "Type the remote destination path for Rsync transfers. Format: user@host:/path." %} -
          - -
          -
          -
          -
          - {% include "tasks/partials/create-form-alert-on-failure-input.html.j2" %} - {% include "tasks/partials/create-form-connectivity-check-input.html.j2" %} -
          -
          - - -
          -
          -
          - diff --git a/templates/mysql_backups/backup/partials/edit-form.html.j2 b/templates/mysql_backups/backup/partials/edit-form.html.j2 deleted file mode 100644 index 7a76c97771..0000000000 --- a/templates/mysql_backups/backup/partials/edit-form.html.j2 +++ /dev/null @@ -1,1323 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} - -{% from "mysql_backups/backup/partials/info-icon.html.j2" import info_icon %} -
          - -
          -

          Edit Backup Task

          -
          -
          -

          - You are about to edit the backup task . -

          -
          -
          - Main Options -
          -
          - Executor Host - {% set hostname_title = "Select the hostname of the target system for task execution." %} -
          - -
          -
          -
          - Basic Task Information -
          - {% set task_name_title = "Type the name of task to be created." %} - -
          -
          - {% set service_id_title = "Select the database host from the inventory to connect to." %} -
          - -
          -
          - {% set backup_type_title = "Select the type of backup: MyDumper or XtraBackup." %} -
          - -
          -
          -
          - {% set alias_title = "Optional alias for the server in the SERVERS_LIST section. Defaults to the service IP if not provided." %} - -
          -
          - {% set encryption_recipient_title = "GPG recipient/key the backup is encrypted for. Required when either encryption mode is enabled." %} - -
          -
          -
          -
          -
          -
          - General Backup Configuration -
          - {% set hardlink_title = "Enable hardlinks to save storage space by linking identical files instead of duplicating them." %} - - {% set compress_title = "Enable compression to reduce backup file size and save storage space." %} - - {% set check_disk_space_title = "Verify available disk space before starting the backup to prevent failures." %} - - {% set encrypt_title = "GPG-encrypt the backup in place. Combine with 'Encrypt using tmpdir', or use 'Encrypt after backup completes' for post-run encryption instead. Either encryption option needs a recipient." %} - - {% set post_run_encrypt_title = "GPG-encrypt the finished backup once it completes. Independent of 'Encrypt backup'; mutually exclusive with 'Encrypt using tmpdir'. Needs an encryption recipient." %} - - {% set encrypt_using_tmpdir_title = "Encrypt in a temporary directory during the backup. Requires 'Encrypt backup'; mutually exclusive with 'Encrypt after backup completes'." %} - - {% set only_if_running_replica_title = "Enable this option to apply the backup only if the server is running as a replica." %} - - {% set only_if_read_only_title = "Enable this option to perform the backup only if the database is in read-only mode." %} - -
          -
          - {% set backup_dir_title = "Type the directory where backup files will be stored." %} - -
          -
          - {% set logging_dir_title = "Type the directory where log files will be stored for backup operations." %} - -
          -
          - {% set defaults_file_title = "the path to the defaults file containing preset configuration values for the backup process." %} - -
          -
          - {% set compression_algorithm_title = "Select the type of compression algorithm" %} - -
          -
          - Mydumper Configuration - {% set mydumper_daily_purge_title = "Set the number of days after which old backups will be automatically deleted." %} - - {% set mydumper_weekly_purge_title = "Set the number of weeks after which old backups will be automatically deleted on a weekly basis." %} - -
          - {% set mydumper_dump_triggers_title = "Include database triggers in the backup to preserve trigger functionality." %} - - {% set mydumper_desync_pxc_title = "Temporarily desynchronize the Percona XtraDB Cluster (PXC) node during backup to reduce replication lag." %} - - {% set mydumper_use_numa_title = "Enable NUMA (Non-Uniform Memory Access) awareness to optimize memory allocation for backup processes." %} - - {% set use_ftwrl_guardian_title = "Enable FTWRL (Flush Tables with Read Lock) Guardian to manage table locks and prevent long lock times during backup." %} - -
          - {% set mydumper_extra_args_title = "Type additional command-line arguments for the backup process to customize its behavior." %} - -
          -
          - Xtrabackup Configuration - {% set xtrabackup_copies_title = "Set the number of backup copies to retain for redundancy and recovery." %} - -
          - {% set xtrabackup_kill_queries_title = "Terminate long-running queries that may block the XtraBackup process to ensure a smooth backup." %} - - {% set xtrabackup_verify_title = "Enable verification of the XtraBackup process to ensure data integrity and consistency." %} - - {% set xtrabackup_prepare_title = "Prepare the XtraBackup files for restoration by applying redo logs and ensuring consistency." %} - - {% set xtrabackup_desync_pxc_title = "Desynchronize the Percona XtraDB Cluster (PXC) node during XtraBackup to minimize replication impact." %} - - {% set xtrabackup_rsync_title = "Use rsync to optimize file transfers during XtraBackup, reducing disk I/O and improving performance." %} - - {% set xtrabackup_replica_info_title = "Include replica information in the backup to assist with replication recovery." %} - - {% set xtrabackup_stop_replica_title = "Stop replication before starting the backup to ensure data consistency." %} - - {% set xtrabackup_lock_ddl_title = "Lock Data Definition Language (DDL) operations during backup to prevent schema changes." %} - -
          - {% set xtrabackup_kill_queries_timeout_title = "Set the maximum time (in seconds) to wait before forcibly terminating long-running queries that block the backup process." %} - - {% set xtrabackup_kill_query_type_title = "Choose which type of queries should be terminated to prevent backup interruptions." %} -
          - -
          - {% set xtrabackup_prepare_memory_title = "Set the amount of memory to allocate for the prepare phase of XtraBackup. Leave empty for default." %} - - {% set xtrabackup_defaults_file_title = "Type the path to the optional defaults file for predefined configuration settings." %} - - {% set xtrabackup_extra_args_title = "Type additional command-line arguments for XtraBackup to customize its behavior." %} - - {% set xtrabackup_incremental_method_title = "Choose the incremental backup method: 'less_space' (reduces storage usage) or 'fast_restore' (accelerates restoration)." %} -
          - -
          - {% set xtrabackup_incremental_cycle_title = "Select the frequency for incremental backups: 'daily' for daily backups or 'weekly' for weekly backups." %} -
          - -
          - {% set xtrabackup_local_ssh_destination_title = "Type the SSH destination for storing backups remotely. Format: user@host:/path" %} - - {% set xtrabackup_aes256_keyfile_title = "Type the path to the AES-256 encryption key file for securing backups." %} - - {% set xtrabackup_bin_cmd_title = "Select the backup tool to use: 'xtrabackup' (default for Percona), 'mariadb-backup' (MariaDB compatibility), or 'innobackupex' (legacy tool)." %} -
          - -
          -
          -
          - Binlog Configuration - {% set binlog_alternative_host_title = "Optional alternative host address for pulling binlogs when different from the main database host." %} - - {% set binlog_prefix_title = "Prefix used for binlog file naming. Default is mysql-bin." %} - - {% set binlog_purge_days_title = "Number of days to retain binlogs before automatically purging old files." %} - - {% set binlog_extra_args_title = "Additional command-line arguments to pass to the mysqlbinlog backup process." %} - - {% set binlog_compress_cmd_title = "Command used to compress binlog backup files after they are written." %} - - {% set binlog_cmd_title = "Path to the mysqlbinlog binary used for creating binlog backups." %} - -
          -
          -
          - Additional Upload Options - - - - -
          -
          - {% set s3_bucket_title = "Type the name of the S3 bucket where backups will be stored." %} - -
          -
          - {% set s3_storage_class_title = "Type the S3 storage class for backups." %} - -
          -
          - {% set awscli_s3_upload_extra_args_title = "Additional ExtraArgs to pass to AWS S3 upload (e.g. ChecksumAlgorithm=CRC32C)" %} - -
          -
          - {% set skip_s3_safety_check_title = "Disable safety checks before uploading backups to S3. Use with caution to avoid accidental data loss." %} - -
          - -
          - - -
          - {% set rsync_path_title = "Type the remote destination path for Rsync transfers. Format: user@host:/path." %} -
          - -
          -
          -
          -
          -
          - {% include "tasks/partials/create-form-alert-on-failure-input.html.j2" %} -
          -
          -
          - - -
          -
          - -
          - diff --git a/templates/mysql_backups/backup/partials/info-icon.html.j2 b/templates/mysql_backups/backup/partials/info-icon.html.j2 deleted file mode 100644 index f31e19d2ce..0000000000 --- a/templates/mysql_backups/backup/partials/info-icon.html.j2 +++ /dev/null @@ -1,24 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% macro info_icon(tooltip_text) %} - {% if tooltip_text %} - info - {% endif %} -{% endmacro %} diff --git a/templates/mysql_backups/backup/partials/saved-tasks.html.j2 b/templates/mysql_backups/backup/partials/saved-tasks.html.j2 deleted file mode 100644 index c1bfecd43f..0000000000 --- a/templates/mysql_backups/backup/partials/saved-tasks.html.j2 +++ /dev/null @@ -1,52 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% if tasks %} - - - - - - - - - - - - - - - {% for task in tasks %} - - - - - - - - - - - {% endfor %} - -
          NameTargetHostTypeUploadCreated byLast Updated byActions
          - {{ task.name }} - {% include "tasks/partials/connectivity-warning-icon.html.j2" %} - {{ task.hostname }}{{ task.host }}{{ task.backup_type }}{{ task.upload }}{{ user_id_to_username.get(task["created_by"], task["created_by"]) or '' }}{{ user_id_to_username.get(task["last_updated_by"], task["last_updated_by"]) or '' }}{% include "tasks/partials/saved-tasks-actions-column.html.j2" %}
          - {% include "tasks/partials/scheduled-execution-dialog.html.j2" %} -{% else %} -

          No data

          -{% endif %} diff --git a/templates/mysql_backups/restore/details.html.j2 b/templates/mysql_backups/restore/details.html.j2 deleted file mode 100644 index fec75746f9..0000000000 --- a/templates/mysql_backups/restore/details.html.j2 +++ /dev/null @@ -1,170 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title -%} - Restores - {{ task.name }} -{%- endblock title %} -{% block style %} - - - - -{% endblock style %} -{% block behaviours_optimized_extra %} - - - {% if history %} - - {% endif %} - -{% endblock behaviours_optimized_extra %} -{% block sticky %} -
          - Backup - Restore -
          -{% endblock sticky %} -{% block main %} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
          Overview
          TargetHostTypeCreated atUpdated atCreated byLast Updated by
          {{ task.hostname }}{{ task.host }} ({{ task.port }}){{ task.restore_type }}{{ task.created_at }}{{ task.updated_at }}{{ user_id_to_username.get(task["created_by"], task["created_by"]) or '' }}{{ user_id_to_username.get(task["last_updated_by"], task["last_updated_by"]) or '' }}
          - {% include "tasks/partials/run-python-form.html.j2" %} - {% if running_tasks %} -
          -

          Running

          - {% include "tasks/partials/running-tasks.html.j2" %} -
          - {% endif %} -
          -

          Execution

          -
          Parameters
          - - - {% for k, v in task.meta.items() %} - {% if not k.startswith('_') %} - - - - - {% endif %} - {% endfor %} - -
          {{ k }} - {% if v is mapping %} -
            - {% for j, w in v.items() %}
          • {{ j }}: {{ w }}
          • {% endfor %} -
          - {% elif v is iterable and v is not string %} - {# TODO: Refactor all these weird tests #} -
            - {% for j, w in v[0].items() %}
          • {{ j }}: {{ w }}
          • {% endfor %} -
          - {% else %} - {{ v|syntax_highlight(style="sas") |safe }} - {% endif %} -
          -
          -
          -
          -
          -

          Stats

          - {% if stats and stats.total %} - - - - - - - - - - - - - - - - - - - - - - -
          Aggregated statistics for {{ task_name }}
          ExecutionsSucceededFailedLast Finished - Avg Duration (s) - - Last Duration (s) -
          {{ stats.total }}{{ stats.status.pass }}{{ stats.status.fail }}{{ stats.last_finished_at }}{{ stats.duration.average_seconds or 0|round(3) }}{{ stats.duration.last_seconds or 0|round(3) }}
          - {% else %} -

          No data

          - {% endif %} -
          -
          -
          -
          -

          History

          - {% set history_tasks = history %} - {% include "tasks/partials/completed-tasks.html.j2" %} -
          - {% include "mysql_backups/restore/partials/edit-form.html.j2" %} -{% endblock main %} diff --git a/templates/mysql_backups/restore/index.html.j2 b/templates/mysql_backups/restore/index.html.j2 deleted file mode 100644 index dd8dc306b2..0000000000 --- a/templates/mysql_backups/restore/index.html.j2 +++ /dev/null @@ -1,123 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title -%} - Restores -{%- endblock title %} -{% block style %} - {% if history_tasks or running_tasks %} - - {% endif %} - {% if tasks %} - - - - - {% endif %} -{% endblock style %} -{% block behaviours_optimized_extra %} - - {% if history_tasks or running_tasks %} - - {% endif %} - {% if tasks %} - - - - - - - - {% endif %} -{% endblock behaviours_optimized_extra %} -{% block sticky %} -
          - Backup - Restore -
          -{% endblock sticky %} -{% block main %} -

          Perform a restore, currently only supporting MySQL and MariaDB.

          - {% if not tasks and not history_tasks %} - {% set modal_required = False %} - {% if services|length < 1 %} -

          Unable to find suitable database instances.

          - {% elif executor_hosts|length < 1 %} -

          Unable to find suitable executor hosts.

          - {% else %} - - {% endif %} - {% else %} - {% set modal_required = True %} - {% if running_tasks %} -
          -

          Running

          - {% include "tasks/partials/running-tasks.html.j2" %} -
          - {% endif %} -
          -

          Saved

          - {% include "/mysql_backups/restore/partials/saved-tasks.html.j2" %} - {% if executor_hosts|length < 1 %} -

          Unable to find suitable executor hosts.

          - {% else %} - - {% endif %} -

          Periodic Tasks

          - {% include "tasks/partials/scheduled-tasks.html.j2" %} -
          -
          -
          -
          -

          Pending

          - {% include "tasks/partials/pending-tasks.html.j2" %} -
          -
          -
          -
          -

          History

          - {% include "tasks/partials/completed-tasks.html.j2" %} -
          - {% endif %} - - {% if executor_hosts|length >= 1 %} - {% include "mysql_backups/restore/partials/create-form.html.j2" %} - {% endif %} -{% endblock main %} diff --git a/templates/mysql_backups/restore/partials/create-form.html.j2 b/templates/mysql_backups/restore/partials/create-form.html.j2 deleted file mode 100644 index e30bcd8a9e..0000000000 --- a/templates/mysql_backups/restore/partials/create-form.html.j2 +++ /dev/null @@ -1,671 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} - -
          - -
          -

          Configure a restore task

          -
          -
          - -
          - Main Options -
          -
          - Executor Host - {% set hostname_title = "Select the hostname of the target system for task execution." %} -
          - -
          -
          -
          - Basic Task Information -
          - {% set task_name_title = "Type the name of task to be created." %} - -
          -
          - {% set backup_type_title = "Select the type of restore: MyLoader or XtraBackup." %} -
          - -
          -
          -
          - General Restore Configuration -
          - {% set backup_source_title = "Type the path or identifier of the backup source to restore from. Examples: 192.168.88.10:/backup/xtrabackup/backup-gascan/latest, s3://dummy/binlogs/primary, s3://dummy/mydumper/backup-gascan/latest" %} - -
          -
          - {% set logging_dir_title = "Type the directory where log files will be stored for restore operations." %} - -
          -
          - {% set port_title = "Type the MySQL port number used to connect to the target database (default is 3306)." %} - -
          -
          - {% set custom_mysql_init_commad_title = "the path to the defaults file containing preset configuration values for the backup process." %} - -
          -
          - {% set ssh_user_title = "Type the SSH username to connect to the target host for restore operations (e.g., 'percona')." %} - -
          -
          - {% set ssh_port_title = "Type the SSH port number used to connect to the target host (default is 22)." %} - -
          -
          - {% set ssh_key_title = "Type the path to the SSH private key file for authentication (optional). Leave blank to use default key." %} - -
          -
          - {% set s3_tool_title = "Select the tool to use for uploading backups to S3 storage (e.g., S3 CMD or AWS CLI)." %} -
          - -
          -
          - {% set gpg_password_file_title = "Type the path to the file containing the GPG password for encrypting or decrypting backups (optional)." %} - -
          -
          -
          -
          - Myloader Configuration - {% set service_id_title = "Select the destination database service where the restore will be applied." %} -
          - -
          -
          - {% set schema_id_title = "Select the destination schema within the database for the restore operation." %} - -
          - {% set local_path_title = "Type the local path where the backup files are stored or will be restored to (optional)." %} - - {% set overwrite_tables_title = "Enable this option to overwrite existing tables during the restore process." %} - - {% set myloader_extra_args_title = "Type any additional command-line arguments for MyLoader to customize the restore process (optional)." %} - - {% set skip_databases_title = "Type a comma-separated list of databases to skip during the restore (optional)." %} - - {% set include_databases_title = "Type a comma-separated list of databases to include in the restore (optional)." %} - - {% set pre_script_title = "Type the path to a script to run before the restore process begins (optional)." %} - - {% set post_title = "Type the path to a script to run after the restore process completes (optional)." %} - - {% set myloader_threads_title = "Set the number of days after which old backups will be automatically deleted." %} - -
          -
          - Xtrabackup Configuration -
          - {% set skip_incrementals_title = "Skip incremental backups during restore process. Useful when you want to restore only the base backup." %} - - {% set kill_mysql_title = "Automatically stop MySQL service before restore and start it after restore is complete." %} - - {% set restore_mycnf_title = "Restore MySQL configuration file (my.cnf) along with the data backup." %} - -
          - {% set datadir_title = "Specify the MySQL data directory where the backup will be restored. Default is usually /var/lib/mysql." %} - - {% set xb_prepare_memory_title = "Amount of memory (in MB) to use for xtrabackup prepare operation. Higher values can speed up the prepare process." %} - - {% set xb_parallel_title = "Number of threads to use for parallel operations during backup restore. Higher values can improve performance but require more system resources." %} - - {% set xtrabackup_bin_cmd_title = "Select the backup tool to use for restore: InnoBackupPex (legacy), XtraBackup (recommended), or MariaDB Backup." %} -
          - -
          - {% set incremental_dest_path_title = "Path where incremental backups will be stored during the restore process." %} - - {% set xtrabackup_restore_args_title = "Additional command-line arguments to pass to xtrabackup during restore operation." %} - - {% set keyring_file_data_title = "Path to the keyring file containing encryption keys for encrypted backups." %} - - {% set xtrabackup_aes256_keyfile_title = "Path to the AES256 key file used for encrypted backups." %} - -
          - {% set slave_from_master_title = "Configure the restored instance as a replica of the specified source server." %} - - {% set wait_for_catchup_title = "Wait for the replica to catch up with the source after restore is complete." %} - -
          - {% set master_ip_title = "IP address of the source server to replicate from." %} - - {% set master_port_title = "Port number of the source server (default is 3306)." %} - - {% set master_user_title = "Username for connecting to the source server." %} - - {% set master_password_title = "Password for the source user account." %} - -
          -
          - Binlog Configuration - {% set start_file_title = "Specify the name of the first binary log file to start the restore from (e.g., binlog.000030)." %} - - {% set start_position_title = "Specify the position within the start file where the restore should begin (e.g., 157)." %} - - {% set stop_file_title = "Specify the name of the last binary log file to stop the restore at (e.g., binlog.000031)." %} - - {% set stop_position_title = "Specify the position within the stop file where the restore should end (e.g., 2995)." %} - - {% set use_sql_file_title = "Specify the path to a SQL file containing additional SQL statements to execute during the restore process." %} - - {% set binlog_restore_extra_args_title = "Specify additional mysqlbinlog command-line arguments (e.g.,--stop-datetime=\"YYYY-MM-DD HH:MM:SS\")." %} - -
          -
          -
          -
          - - -
          -
          -
          - - diff --git a/templates/mysql_backups/restore/partials/edit-form.html.j2 b/templates/mysql_backups/restore/partials/edit-form.html.j2 deleted file mode 100644 index 5eee053a13..0000000000 --- a/templates/mysql_backups/restore/partials/edit-form.html.j2 +++ /dev/null @@ -1,821 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} - -
          - -
          -

          Edit Restore Task

          -
          -
          -

          - You are about to edit the restore task . -

          -
          -
          - Main Options -
          -
          - Executor Host - {% set hostname_title = "Select the hostname of the target system for task execution." %} -
          - -
          -
          -
          - Basic Task Information -
          - {% set task_name_title = "Type the name of task to be created." %} - -
          -
          - {% set backup_type_title = "Select the type of restore: MyLoader or XtraBackup." %} -
          - -
          -
          -
          - General Restore Configuration -
          - {% set backup_source_title = "Type the path or identifier of the backup source to restore from. Examples: 192.168.88.10:/backup/xtrabackup/backup-gascan/latest, s3://dummy/binlogs/primary, s3://dummy/mydumper/backup-gascan/latest" %} - -
          -
          - {% set logging_dir_title = "Type the directory where log files will be stored for restore operations." %} - -
          -
          - {% set port_title = "Type the MySQL port number used to connect to the target database (default is 3306)." %} - -
          -
          - {% set custom_mysql_init_commad_title = "the path to the defaults file containing preset configuration values for the backup process." %} - -
          -
          - {% set ssh_user_title = "Type the SSH username to connect to the target host for restore operations (e.g., 'percona')." %} - -
          -
          - {% set ssh_port_title = "Type the SSH port number used to connect to the target host (default is 22)." %} - -
          -
          - {% set ssh_key_title = "Type the path to the SSH private key file for authentication (optional). Leave blank to use default key." %} - -
          -
          - {% set s3_tool_title = "Select the tool to use for uploading backups to S3 storage (e.g., S3 CMD or AWS CLI)." %} -
          - -
          -
          - {% set gpg_password_file_title = "Type the path to the file containing the GPG password for encrypting or decrypting backups (optional)." %} - -
          -
          -
          -
          - Myloader Configuration - {% set service_id_title = "Select the destination database service where the restore will be applied." %} -
          - -
          -
          - {% set schema_id_title = "Select the destination schema within the database for the restore operation." %} - -
          - {% set local_path_title = "Type the local path where the backup files are stored or will be restored to (optional)." %} - - {% set overwrite_tables_title = "Enable this option to overwrite existing tables during the restore process." %} - - {% set myloader_extra_args_title = "Type any additional command-line arguments for MyLoader to customize the restore process (optional)." %} - - {% set skip_databases_title = "Type a comma-separated list of databases to skip during the restore (optional)." %} - - {% set include_databases_title = "Type a comma-separated list of databases to include in the restore (optional)." %} - - {% set pre_script_title = "Type the path to a script to run before the restore process begins (optional)." %} - - {% set post_title = "Type the path to a script to run after the restore process completes (optional)." %} - - {% set myloader_threads_title = "Set the number of days after which old backups will be automatically deleted." %} - -
          -
          - Xtrabackup Configuration -
          - {% set skip_incrementals_title = "Skip incremental backups during restore process. Useful when you want to restore only the base backup." %} - - {% set kill_mysql_title = "Automatically stop MySQL service before restore and start it after restore is complete." %} - - {% set restore_mycnf_title = "Restore MySQL configuration file (my.cnf) along with the data backup." %} - -
          - {% set datadir_title = "Specify the MySQL data directory where the backup will be restored. Default is usually /var/lib/mysql." %} - - {% set xb_prepare_memory_title = "Amount of memory (in MB) to use for xtrabackup prepare operation. Higher values can speed up the prepare process." %} - - {% set xb_parallel_title = "Number of threads to use for parallel operations during backup restore. Higher values can improve performance but require more system resources." %} - - {% set xtrabackup_bin_cmd_title = "Select the backup tool to use for restore: InnoBackupPex (legacy), XtraBackup (recommended), or MariaDB Backup." %} -
          - -
          - {% set incremental_dest_path_title = "Path where incremental backups will be stored during the restore process." %} - - {% set xtrabackup_restore_args_title = "Additional command-line arguments to pass to xtrabackup during restore operation." %} - - {% set keyring_file_data_title = "Path to the keyring file containing encryption keys for encrypted backups." %} - - {% set xtrabackup_aes256_keyfile_title = "Path to the AES256 key file used for encrypted backups." %} - -
          - {% set slave_from_master_title = "Configure the restored instance as a replica of the specified source server." %} - - {% set wait_for_catchup_title = "Wait for the replica to catch up with the source after restore is complete." %} - -
          - {% set master_ip_title = "IP address of the source server to replicate from." %} - - {% set master_port_title = "Port number of the source server (default is 3306)." %} - - {% set master_user_title = "Username for connecting to the source server." %} - - {% set master_password_title = "Password for the source user account." %} - -
          -
          - Binlog Configuration - {% set start_file_title = "Specify the name of the first binary log file to start the restore from (e.g., binlog.000030)." %} - - {% set start_position_title = "Specify the position within the start file where the restore should begin (e.g., 157)." %} - - {% set stop_file_title = "Specify the name of the last binary log file to stop the restore at (e.g., binlog.000031)." %} - - {% set stop_position_title = "Specify the position within the stop file where the restore should end (e.g., 2995)." %} - - {% set use_sql_file_title = "Specify the path to a SQL file containing additional SQL statements to execute during the restore process." %} - - {% set binlog_restore_extra_args_title = "Specify additional mysqlbinlog command-line arguments (e.g., --stop-datetime=\"YYYY-MM-DD HH:MM:SS\")." %} - -
          -
          -
          - {% include "tasks/partials/create-form-alert-on-failure-input.html.j2" %} -
          -
          -
          - - -
          -
          - -
          -{% set source_data = { "host": task.host, "port": task.port, "database": task.database } %} - - diff --git a/templates/mysql_backups/restore/partials/saved-tasks.html.j2 b/templates/mysql_backups/restore/partials/saved-tasks.html.j2 deleted file mode 100644 index a32024a952..0000000000 --- a/templates/mysql_backups/restore/partials/saved-tasks.html.j2 +++ /dev/null @@ -1,46 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% if tasks %} - - - - - - - - - - - - - {% for task in tasks %} - - - - - - - - - {% endfor %} - -
          NameHostTypeCreated byLast Updated byActions
          - {{ task.name }} - {{ task.host }} ({{ task.port }}){{ task.backup_type }}{{ user_id_to_username.get(task["created_by"], task["created_by"]) or '' }}{{ user_id_to_username.get(task["last_updated_by"], task["last_updated_by"]) or '' }}{% include "tasks/partials/saved-tasks-actions-column.html.j2" %}
          -{% else %} -

          No data

          -{% endif %} diff --git a/templates/report/index.html.j2 b/templates/report/index.html.j2 deleted file mode 100644 index 0de25e12f7..0000000000 --- a/templates/report/index.html.j2 +++ /dev/null @@ -1,116 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title -%} - Health & Security Report -{%- endblock title %} -{% block style %} - -{% endblock style %} -{% block main %} -
          -

          Health & Security Report

          -

          - Generate a comprehensive report of the database health, alerts, - backups, disk usage, service uptime, and inventory from PMM. -

          - {% if not pmm_configured %} -
          - - PMM is not configured. Please set the PMM endpoint and API key - in the application settings. - -
          - {% else %} -
          - -
          - Report Period -
          - - -
          -
          - - -
          -
          -
          - Options -
          - - -
          -
          - - -
          -
          -
          - - -
          -
          - {% endif %} -
          -{% endblock main %} diff --git a/templates/report/job.html.j2 b/templates/report/job.html.j2 deleted file mode 100644 index b63a2b867e..0000000000 --- a/templates/report/job.html.j2 +++ /dev/null @@ -1,79 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title -%} - {{ job_title }} -{%- endblock title %} -{% block style %} - -{% endblock style %} -{% block main %} -
          -
          -

          {{ job_title }}

          -

          {{ job_description }}

          -
          -
          -

          Queued.

          -

          - - Back to Report -

          -
          -
          -{% endblock main %} -{% block behaviours_optimized_extra %} - -{% endblock behaviours_optimized_extra %} diff --git a/templates/report/result.html.j2 b/templates/report/result.html.j2 deleted file mode 100644 index b498c95e75..0000000000 --- a/templates/report/result.html.j2 +++ /dev/null @@ -1,685 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title -%} - {{ report.metadata.title }} -{%- endblock title %} -{% block style %} - -{% endblock style %} -{% block main %} -
          -
          -

          {{ report.metadata.title }}

          -

          {{ report.metadata.report_week }}

          -

          {{ report.metadata.report_interval }}

          -
          -
          - - - -
          -
          - - - -
          - Back to Report -
          -
          -
          -

          - This report presents a comprehensive overview of the current - status of the database health, aiming to provide you with - valuable insights into critical aspects of system performance. -

          -

          - In the following sections, we delve into key metrics, including - database alerts, uptime, health checks, and backup status, to - offer a holistic perspective on the system's operational health. - By examining these parameters, we seek to identify strengths - and potential areas for enhancement. The ultimate goal is to - present a clear picture of your database's reliability, - scalability, overall health, and suggestions for improvements. -

          -

          - Disclaimer: The Percona ExpertOps team is here to assist - with any database-related concerns. However, if you encounter - issues related to the operating system, we kindly ask that you - address those directly. We're happy to help with anything - related to your database performance and functionality. -

          -
          - {# Monitored summary #} -
          -

          Monitored Infrastructure

          -
          -
          Total nodes
          -
          - {{ report.monitored.total_nodes }} -
          -
          Total services
          -
          - {{ report.monitored.total_services }} -
          - {% for svc_type, count in report.monitored.services_by_type.items() %} -
          {{ service_names.get(svc_type, svc_type) }}
          -
          - {{ count }} -
          - {% endfor %} -
          -
          - {# Table of Contents #} - - {# ================================================================ #} - {# Advisors #} - {# ================================================================ #} -
          -

          Advisors

          - {% if not report.full %} -

          - Please note that this report was configured to only report failed advisors. -

          - {% endif %} -
          -
          Total checks
          -
          - {{ report.advisors.total_checks }} -
          - {% if report.refresh %} -
          Total refresh timeouts
          -
          - {{ report.advisors.refresh_issues | length }} -
          - {% endif %} -
          Total failed checks
          -
          - {{ report.advisors.total_failed }} -
          -
          - {% for family in report.advisors.families %} -
          -

          {{ family.display_name }} Advisors

          - {% set ns = namespace(has_failures=false) %} - {% for check in family.checks %} - {% set check_failed = family.failed.get(check.name, []) %} - {% if report.full or check_failed %} - - - - - - - - - - - - - - - - {% if check_failed %} - {% set ns.has_failures = true %} - - {% for fc in check_failed %} - - - - - - - {% endfor %} - - {% else %} - - - - - - {% endif %} -
          -

          {{ check.summary }} ({{ check.name }})

          -
          HostServiceStatusReason
          -

          {{ check.description }}

          -
          {{ fc.node_name or "Unknown" }}{{ fc.service_name or "Unknown" }} - {% if fc.severity.value == "SEVERITY_NOTICE" %} - ❗ - {% else %} - ❌ - {% endif %} - - {% if fc.read_more_url %} - {{ fc.summary }} - {% else %} - {{ fc.summary }} - {% endif %} -
          -

          ✔ All services passed

          -
          - {% endif %} - {% endfor %} - {% if not ns.has_failures and not report.full %}

          ✔ No failed advisors.

          {% endif %} -
          - {% endfor %} - {% if not report.advisors.families %}

          No advisor data available.

          {% endif %} -
          - {# ================================================================ #} - {# Alerts #} - {# ================================================================ #} -
          -

          Alerts

          - {% if not report.full %} -

          - Please note that this report was configured to only report alert overview. -

          - {% endif %} -
          -
          Total alerts
          -
          - {{ report.alerts.total_alerts }} -
          -
          - {% if report.alerts.total_alerts > 0 %} - {# Alerts per service #} - {% if report.alerts.alerts_per_service %} - - - - - - - - - - - - - - - {% for svc, count in report.alerts.alerts_per_service.items() | sort %} - - - - - {% endfor %} - -
          Total alerts per service
          Service TypeCount
          {{ service_names.get(svc, svc) }}{{ count }}
          - {% endif %} - {# Alerts per rule #} - {% if report.alerts.alerts_per_rule %} - - - - - - - - - - - - - - - {% for rule, count in report.alerts.alerts_per_rule.items() | sort %} - - - - - {% endfor %} - -
          Total alerts per rule
          RuleCount
          {{ rule }}{{ count }}
          - {% endif %} - {# Alerts per host #} - {% if report.alerts.alerts_per_host %} - - - - - - - - - - - - - - - {% for host, count in report.alerts.alerts_per_host.items() | sort %} - - - - - {% endfor %} - -
          Total alerts per host
          HostCount
          {{ host }}{{ count }}
          - {% endif %} - {# Daily alerts #} - {% if report.alerts.alerts_daily %} - - - - - - - - - - - - - - - {% for day, count in report.alerts.alerts_daily.items() %} - - - - - {% endfor %} - -
          Total daily alerts
          DateCount
          {{ day }}{{ count }}
          - {% endif %} - {# Daily alerts per host #} - {% if report.alerts.alerts_daily_per_host %} - - - - - - - - - - - - - - - - {% for host, days in report.alerts.alerts_daily_per_host.items() | sort %} - {% for day, count in days.items() %} - - - - - - {% endfor %} - {% endfor %} - -
          Total daily alerts per host
          DateHostCount
          {{ day }}{{ host }}{{ count }}
          - {% endif %} - {# Alert history (full mode only) #} - {% if report.full and report.alerts.alert_history %} - - - - - - - - - - - - - - - - - {% for alert in report.alerts.alert_history %} - {% set alert_ts = alert.time // 1000 %} - - - - - - - {% endfor %} - -
          Alert history
          DateRuleHostService
          {{ alert_ts | timestamp_format }}{{ alert.alertname.rsplit('_', 1)[-1] }}{{ alert.node_name or "Unknown" }}{{ service_names.get(alert.service_type) or service_names.get(alert.service) or "Unknown" }}
          - {% endif %} - {% else %} -

          No alerts for this period.

          - {% endif %} -
          - {# ================================================================ #} - {# Backups #} - {# ================================================================ #} -
          -

          Backups

          - {% if not report.full %} -

          - Please note that this report was configured to only report backup overview. -

          - {% endif %} -
          -
          Total backups
          -
          - {{ report.backups.total_backups }} -
          -
          - {% if report.backups.total_backups > 0 %} - {# Backups by host #} - {% if report.backups.backups_by_host %} - - - - - - - - - - - - - - - {% for host, count in report.backups.backups_by_host.items() | sort %} - - - - - {% endfor %} - -
          Total backups by host
          HostCount
          {{ host }}{{ count }}
          - {% endif %} - {# Backups by status #} - {% if report.backups.backups_by_status %} - - - - - - - - - - - - - - - {% for bk_status, count in report.backups.backups_by_status.items() | sort %} - - - - - {% endfor %} - -
          Total backups by status
          StatusCount
          {{ bk_status }}{{ count }}
          - {% endif %} - {# Backups by type #} - {% if report.backups.backups_by_type %} - - - - - - - - - - - - - - - {% for bk_type, count in report.backups.backups_by_type.items() | sort %} - - - - - {% endfor %} - -
          Total backups by type
          TypeCount
          {{ bk_type }}{{ count }}
          - {% endif %} - {# Failed backups #} - {% if report.backups.backups_by_status.get("fail", 0) > 0 and report.backups.failed_backups %} - - - - - - - - - - - - - - - - - - {% for bk in report.backups.failed_backups | sort(attribute="period.start", reverse=true) %} - {% if bk.type != "Binlogs" %} - - - - - - - - {% endif %} - {% endfor %} - -
          Failed backups
          HostTargetDateTypeNotes
          A non-zero exit code was produced by the backup. Sorted date in reverse order.
          {{ bk.name }}{{ bk.alias }}{{ bk.period.start | backup_date }}{{ bk.type }} - {% if bk.estimated_data %}

          Some data is estimated.

          {% endif %} - {% if bk.enabled is false %}

          Currently disabled.

          {% endif %} -
          - {% endif %} - {# Backup history / Last 10 #} - {% set non_binlog_backups = report.backups.all_backups | rejectattr("type", "equalto", "Binlogs") | list %} - {% if non_binlog_backups %} - - {% if report.full %} - - {% else %} - - {% endif %} - - - - - - - - - - - - - - - - - - - - {% set sorted_backups = non_binlog_backups | sort(attribute="period.start", reverse=true) %} - {% if not report.full %} - {% set sorted_backups = sorted_backups[:10] %} - {% endif %} - {% for bk in sorted_backups %} - - - - - - - - - - - - {% endfor %} - -
          Full backup historyLast 10 Backups
          IDHostTargetDateTypeStatusSizeDurationNotes
          Sorted date in reverse order.
          {{ bk.id }}{{ bk.name }}{{ bk.alias }}{{ bk.period.start | backup_date }}{{ bk.type }}{{ bk.status.value }}{{ bk.size | convert_bytes }}{{ bk.period.duration }} - {% if bk.estimated_data %}

          Some data is estimated.

          {% endif %} - {% if bk.enabled is false %}

          Currently disabled.

          {% endif %} -
          - {% endif %} - {% else %} -

          No backup data for this period.

          - {% endif %} -
          - {# ================================================================ #} - {# Storage #} - {# ================================================================ #} -
          -

          Disk Usage

          - {% if report.storage.entries %} - - - - - - - - - - - - - - - - - - - {% for e in report.storage.entries %} - - - - - - - - - {% endfor %} - -
          Breakdown by host and mountpoint
          HostMountpointCapacityUsed space %GrowthPeak usage
          Sorted usage in reverse order.
          {{ e.node_name }}{{ e.mountpoint }}{{ "%.0f"|format(e.capacity_bytes / 1073741824) }}GB{{ e.usage_percentage }}%{{ "%.0f"|format((e.used_end_bytes - e.used_start_bytes) / 1048576) }}MB{{ "%.0f"|format(e.used_peak_bytes / 1073741824) }}GB
          - {% else %} -

          No storage data available.

          - {% endif %} -
          - {# ================================================================ #} - {# Uptime #} - {# ================================================================ #} -
          -

          Service Uptime

          - {% if report.uptime.entries %} - - - - - - - - - - - - - - - - {% for e in report.uptime.entries %} - - - - - - {% endfor %} - -
          All services
          Service NameUptimeSince
          Sorted uptime in reverse order.
          {{ e.service_name }}{{ e.uptime }}{{ e.since }}
          - {% else %} -

          No uptime data available.

          - {% endif %} -
          - {# ================================================================ #} - {# Inventory #} - {# ================================================================ #} -
          -

          Included Services

          - {% if report.inventory.entries %} - - - - - - - - - - - - - - - - - {% for e in report.inventory.entries %} - - - - - - - {% endfor %} - -
          All services
          Service NameService TypeNode NameAgents Status
          Sorted by Service Type and Service Name.
          {{ e.service_name }}{{ e.service_type }}{{ e.node_name }} - {% if e.status.value == "OK" %} - ✔ - {% else %} - ✖ - {% endif %} - {{ e.status.value }} -
          - {% else %} -

          No inventory data available.

          - {% endif %} -
          -
          -

          Generated {{ report.metadata.generated_at.strftime("%Y-%m-%d %H:%M:%S %Z") }}

          - Back to Report -
          -
          -{% endblock main %} diff --git a/templates/snippets/details.html.j2 b/templates/snippets/details.html.j2 deleted file mode 100644 index 56168aa1fd..0000000000 --- a/templates/snippets/details.html.j2 +++ /dev/null @@ -1,182 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title -%} - Snippets - {{ snippet.title }} -{%- endblock title %} -{% block style %} - - {% if history_tasks or running_tasks %} - - {% endif %} - {% if snippet_preview %}{% endif %} -{% endblock style %} -{% block behaviours_optimized_extra %} - - - - {% if snippet.is_approved %} - - {% endif %} - {% if history_tasks or running_tasks %} - - {% endif %} -{% endblock behaviours_optimized_extra %} -{% block main %} -

          Snippet - {{ snippet.title }}

          -
          -
          Filename
          -
          -
          {{ snippet.filename }}
          -
          - {% if snippet.description %} -
          Description
          -
          - {{ snippet.description }} -
          - {% endif %} -
          Interpreter
          - {% if snippet.execution_interpreter is none -%} -
          - No interpreter is configured for extension '{{ snippet.path.suffix.lower() }}' and MIME type '{{ snippet.mime_type }}' -
          - {% else -%} -
          -
          {{ snippet.execution_interpreter }}
          -
          - {% endif %} -
          Sudo
          -
          - {{ snippet.sudo.name|capitalize }} -
          -
          Created at
          -
          - {{ snippet.created_at|utc_isoformat }} -
          -
          Size
          -
          - {{ snippet.size|humanize_bytes }} -
          -
          Status
          - {% if snippet.is_approved -%} -
          - Approved at {{ snippet.approved_at|utc_isoformat }} check_circle -
          - {%- else -%} - {%- set download_before_approval = snippet_preview.is_truncated or not (snippet_preview.content or snippet_preview.preamble or snippet_preview.frontmatter) -%} -
          - Not approved unpublished -
          - {%- endif -%} -
          Reason
          -
          - {{ snippet.reason }} -
          -
          -
          - {% include "snippets/partials/download-approve.html.j2" %} -
          - {% if running_tasks %} -
          -

          Running

          - {% include "tasks/partials/running-tasks.html.j2" %} -
          - {% endif %} -
          -

          - Preview - {% if snippet_preview and snippet_preview.is_truncated %} - (partial) - {% endif %} -

          - {% if snippet_preview -%} - {% if snippet_preview.frontmatter -%} - {%- set last_line = snippet_preview.code_linenostart + snippet_preview.content.splitlines()|length - 1 -%} - {%- set linenos_chars = last_line|string|length -%} -
          - {% if snippet_preview.preamble -%} - {{ snippet_preview.preamble|syntax_highlight(style="monokai", stripall=False, linenos=True, wrapcode=True) |safe }} - {%- endif %} -
          - ▶ frontmatter ({{ snippet_preview.frontmatter_line_count }} lines) - {{ snippet_preview.frontmatter|syntax_highlight(style="monokai", stripall=False, linenos=True, wrapcode=True, linenostart=snippet_preview.preamble_line_count + 1) |safe }} -
          - {% if snippet_preview.content -%} - {{ snippet_preview.content|syntax_highlight(style="monokai", stripall=False, linenos=True, wrapcode=True, linenostart=snippet_preview.code_linenostart) |safe }} - {%- endif %} -
          - {% else -%} - {{ snippet_preview.content|syntax_highlight(style="monokai", linenos=True, wrapcode=True) |safe }} - {%- endif %} - {% if snippet_preview.is_truncated -%} -

          - warning File is too large to preview. Please download the snippet to inspect its full contents. -

          - {%- endif -%} - {%- else -%} - No preview available. Please download the snippet to inspect its contents. - {%- endif -%} -
          - {% if snippet.is_approved -%} -
          -

          Execute Snippet

          - {% if not snippet.can_execute %} - Execution is disabled - {%- if snippet.validated_parameters.errors -%} - : snippet has invalid parameters - {%- elif snippet.execution_interpreter is none -%} - : no interpreter is configured for extension '{{ snippet.path.suffix.lower() }}' and MIME type '{{ snippet.mime_type }}' - {%- endif -%} - - {% endif %} - {{ snippet.to_form(executor_hosts, snippet_execute_action) | safe }} - -
          - {% endif -%} - {% if history_tasks %} -
          -

          History

          - {% include "tasks/partials/completed-tasks.html.j2" %} -
          - {% endif %} -{% endblock main %} diff --git a/templates/snippets/index.html.j2 b/templates/snippets/index.html.j2 deleted file mode 100644 index 6fb892dafe..0000000000 --- a/templates/snippets/index.html.j2 +++ /dev/null @@ -1,55 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title -%} - Support Snippets -{%- endblock title %} -{% block style %} - {% if snippets %} - - - {% endif %} -{% endblock style %} -{% block behaviours_optimized_extra %} - - -{% endblock behaviours_optimized_extra %} -{% block main %} -
          -
          -

          Snippets Manager

          - {% if enable_snippets_refresh and user.is_admin %} -
          - - -
          - {% endif %} -
          - {% include "snippets/partials/saved-snippets.html.j2" %} -
          -{% endblock main %} diff --git a/templates/snippets/partials/download-approve.html.j2 b/templates/snippets/partials/download-approve.html.j2 deleted file mode 100644 index 46cc80d7f1..0000000000 --- a/templates/snippets/partials/download-approve.html.j2 +++ /dev/null @@ -1,44 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} - - - -{% if user.is_admin -%} - {%- if snippet.is_approved -%} - {%- set action_route = "snippets_remove_approval" -%} - {%- set action_verb = "remove the approval of" -%} - {%- set icon = "remove_done" -%} - {%- else -%} - {%- set action_route = "snippets_approve" -%} - {%- set action_verb = "approve" -%} - {%- set icon = "done_all" -%} - {%- endif -%} - {%- set submit_title = action_verb.capitalize() ~ " snippet" -%} -
          - - -
          -{%- endif %} diff --git a/templates/snippets/partials/saved-snippets.html.j2 b/templates/snippets/partials/saved-snippets.html.j2 deleted file mode 100644 index 7909c5475c..0000000000 --- a/templates/snippets/partials/saved-snippets.html.j2 +++ /dev/null @@ -1,115 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% if snippets %} - {% set has_unapproved = snippets | rejectattr('is_approved') | list | length > 0 %} - {% set show_batch_ui = user.is_admin and has_unapproved %} - {% if show_batch_ui %} -
          - -
          - - {% endif %} - - - - {% if show_batch_ui %} - - {% endif %} - - - - - - - - - - - {% for snippet in snippets %} - - {% if show_batch_ui %} - - {% endif %} - - - - - - - - - {% endfor %} - -
          - - FilenameDescriptionSizeAdded atReasonActions
          - {% if not snippet.is_approved %} - - {% endif %} - - {% if snippet.is_approved %} - check_circle - {% else %} - {%- set download_before_approval = True -%} - unpublished - {% endif %} - {{ snippet.filename }}{{ snippet.description|truncate(120) }}{{ snippet.size|humanize_bytes }}{{ snippet.created_at|utc_isoformat }}{{ snippet.reason }} -
          - - - - {% include "snippets/partials/download-approve.html.j2" %} -
          -
          -{% else %} -

          No data

          -{% endif %} diff --git a/templates/tasks/list.html.j2 b/templates/tasks/list.html.j2 deleted file mode 100644 index 9bb311ecd9..0000000000 --- a/templates/tasks/list.html.j2 +++ /dev/null @@ -1,73 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block title -%} - Tasks -{%- endblock title %} -{% block style %} - - {% if running_tasks %}{% endif %} -{% endblock style %} -{% block behaviours_optimized_extra %} - - {% if running_tasks %} - - {% endif %} -{% endblock behaviours_optimized_extra %} -{% block main %} -

          Manage the configuration, execution and reporting with the task manager.

          - {% if running_tasks %} -
          -

          Running

          - {% include "tasks/partials/running-tasks.html.j2" %} -
          - {% endif %} -

          Available tasks

          - - - - - - - - - - - - - - - - - - {% for task in tasks %} - - - - - - - - {% endfor %} - -
          Tasks available for reuse
          TaskEngineCreatedCreated byLast updated by
          - {{ task["name"] }} - {{ task["backend"].capitalize() }}{{ task["created_at"] }}{{ user_id_to_username.get(task["created_by"], task["created_by"]) or '' }}{{ user_id_to_username.get(task["last_updated_by"], task["last_updated_by"]) or '' }}
          -{% endblock main %} diff --git a/templates/tasks/partials/chain-builder.html b/templates/tasks/partials/chain-builder.html deleted file mode 100644 index 8dede977ea..0000000000 --- a/templates/tasks/partials/chain-builder.html +++ /dev/null @@ -1,43 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% if chainable_tasks is defined and chainable_tasks %} -
          - -
          -
          - -
          - -
          -{% endif %} diff --git a/templates/tasks/partials/chain-display.html b/templates/tasks/partials/chain-display.html deleted file mode 100644 index 86dadc6915..0000000000 --- a/templates/tasks/partials/chain-display.html +++ /dev/null @@ -1,26 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% if chain_names %} -
          - {% for name in chain_names %} - {{ name }} - {% if not loop.last %}{% endif %} - {% endfor %} -
          -{% else %} - — -{% endif %} diff --git a/templates/tasks/partials/completed-tasks.html.j2 b/templates/tasks/partials/completed-tasks.html.j2 deleted file mode 100644 index ec119a9d6d..0000000000 --- a/templates/tasks/partials/completed-tasks.html.j2 +++ /dev/null @@ -1,218 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% if history_tasks is defined and history_tasks|length > 0 %} - - - - - - - - - - - - - - - {% for task_history in history_tasks %} - {%- set task_logs = task_history.has_logs -%} - {% if task_history.status in ['success', 'failed', 'stopped', 'lost', 'stale'] %} - - - - - - - - - - - {% endif %} - {% endfor %} - -
          NameChainStatusStarted atCompleted atDuration (s)Executed byView logs
          {{ task_history.task.name }} - {% set chain = task_history.execution_request.meta.get('_chain_task_names', []) if task_history.execution_request and task_history.execution_request.meta else [] %} - {% if chain %} - {% with chain_names=chain %} - {% include "tasks/partials/chain-display.html" %} - {% endwith %} - {% elif task_history.execution_request and task_history.execution_request.meta and task_history.execution_request.meta.get('_chain_depth', 0) > 0 %} - - link - - {% else %} - — - {% endif %} - - {% if task_history.status == 'success' %} -
          - check - Done -
          - {% elif task_history.status == 'stopped' %} -
          - cancel - Stopped -
          - {% elif task_history.status == 'lost' %} -
          - question_mark - Lost -
          - {% elif task_history.status == 'stale' %} -
          - hourglass_disabled - Stale -
          - {% else %} -
          - report - Failed -
          - {% endif %} -
          {{ task_history.started_at }}{{ task_history.finished_at }}{{ task_history.duration }}{{ user_id_to_username.get(task_history.executed_by, task_history.executed_by) or '' }} - {%- if task_logs %} - -
          - -
          -
          - -
          - -
          -
          -
          -
          -
          -
          - -
          -
          -
          -
          - {%- endif -%} - {%- if task_history.available_files -%} - -
          - -
          -
          - -
          -
          - - - - - - - - - - {% for filename, meta in task_history.available_files.items() %} - {% set is_dir = meta.is_dir if meta is mapping else False %} - {% set size = meta.size if meta is mapping else meta %} - {% set display_name = filename ~ ('/' if is_dir else '') %} - - - - - - {% endfor %} - -
          FileSizeDownload
          {{ display_name }}{{ size }} - -
          -
          -
          - -
          -
          -
          -
          - {%- endif -%} -
          -{% else %} - No data -{% endif %} diff --git a/templates/tasks/partials/connectivity-warning-icon.html.j2 b/templates/tasks/partials/connectivity-warning-icon.html.j2 deleted file mode 100644 index 05a7c39be8..0000000000 --- a/templates/tasks/partials/connectivity-warning-icon.html.j2 +++ /dev/null @@ -1,24 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% if task._connectivity_warning %} - cloud_off -{% endif %} diff --git a/templates/tasks/partials/create-form-alert-on-failure-input.html.j2 b/templates/tasks/partials/create-form-alert-on-failure-input.html.j2 deleted file mode 100644 index 3cc1cfcd11..0000000000 --- a/templates/tasks/partials/create-form-alert-on-failure-input.html.j2 +++ /dev/null @@ -1,32 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -
          - -
          diff --git a/templates/tasks/partials/create-form-connectivity-check-input.html.j2 b/templates/tasks/partials/create-form-connectivity-check-input.html.j2 deleted file mode 100644 index 05d30adae2..0000000000 --- a/templates/tasks/partials/create-form-connectivity-check-input.html.j2 +++ /dev/null @@ -1,30 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -
          - -
          diff --git a/templates/tasks/partials/create-form.html.j2 b/templates/tasks/partials/create-form.html.j2 deleted file mode 100644 index 647ea90b87..0000000000 --- a/templates/tasks/partials/create-form.html.j2 +++ /dev/null @@ -1,95 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -

          Create a new task

          -
          - -
          - Enter a new task definition -
          -
          - -
          -
          - -
          -
          -
          - Format - - - -
          -
          -
          - -
          -
          - -
          -
          -
          - {% include "tasks/partials/create-form-alert-on-failure-input.html.j2" %} -
          -
          - -
          - diff --git a/templates/tasks/partials/host-metrics.html.j2 b/templates/tasks/partials/host-metrics.html.j2 deleted file mode 100644 index 7412a96d37..0000000000 --- a/templates/tasks/partials/host-metrics.html.j2 +++ /dev/null @@ -1,48 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -
          -

          - Targets - {% if executor_hosts %} - ({{ executor_hosts | length }}) - {% else %} - (None) - {% endif %} -

          -
          - - - - - - - {% if executor_hosts %} - {% for targetname, ip in executor_hosts %} - - - - - {% endfor %} - {% else %} - - - - {% endif %} - -
          AddressHostname
          {{ ip }}{{ targetname }}
          No targets available.
          -
          -
          diff --git a/templates/tasks/partials/pending-tasks.html.j2 b/templates/tasks/partials/pending-tasks.html.j2 deleted file mode 100644 index 58fee7c1ba..0000000000 --- a/templates/tasks/partials/pending-tasks.html.j2 +++ /dev/null @@ -1,59 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% if pending_tasks %} - - - - - - - - - - - - - {% for task in pending_tasks %} - {% set task_name = task["task"]["name"] %} - - - - - {% set eta = task['execution_request'].get('eta') %} - - - - - {% endfor %} - -
          NameHostCreated atETAExecuted byOptions
          {{ task_name }}{{ task['execution_request']['target'] }}{{ task['created_at'] }}{{ eta }}{{ user_id_to_username.get(task.executed_by, task.executed_by) or '' }} - - visibility - - - cancel - -
          - -
          -
          -{% else %} - - No data -{% endif %} diff --git a/templates/tasks/partials/run-python-form-alters.html.j2 b/templates/tasks/partials/run-python-form-alters.html.j2 deleted file mode 100644 index 0cd4a30e19..0000000000 --- a/templates/tasks/partials/run-python-form-alters.html.j2 +++ /dev/null @@ -1,166 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -
          - - - {% if task_data and task_data.get("ParameterizedJob") %} -
          - Configuration - {% if task_data.ParameterizedJob %} - {% set optional = task_data.ParameterizedJob.get("MetaOptional", []) %} - {% set required = task_data.ParameterizedJob.get("MetaRequired", []) %} - {% else %} - {% set optional = [] %} - {% set required = [] %} - {% endif %} - {% set meta_defaults = task_data.get("Meta",{}) %} - {% for item in required %} -
          - - {% if item == "target" %} - {% include "tasks/partials/select-host.html.j2" %} - {% else %} - - {% endif %} -
          - {% endfor %} - {% for item in optional %} -
          - - {% if item == "target" %} - {% include "tasks/partials/select-host.html.j2" %} - {% else %} - - {% endif %} -
          - {% endfor %} - {% set nomad_payload_rule = task_data["ParameterizedJob"].get("Payload", "optional") %} - {% if nomad_payload_rule in ["optional", "required"] %} -
          - - -
          - {% endif %} -
          - {% endif %} - {% with chain_task_name=task.name, chain_form_id=task.name ~ "-send" %} - {% include "tasks/partials/chain-builder.html" %} - {% endwith %} -
          - - - - - {% if not task.protected %} - - - {% endif %} -
          -
          -
          - -
          -
          - -
          -
          - -
          -
          - {% include "tasks/partials/scheduled-execution-dialog.html.j2" %} -
          -
          -
          diff --git a/templates/tasks/partials/run-python-form-backup_mongo.html.j2 b/templates/tasks/partials/run-python-form-backup_mongo.html.j2 deleted file mode 100644 index d0cc2bb4fa..0000000000 --- a/templates/tasks/partials/run-python-form-backup_mongo.html.j2 +++ /dev/null @@ -1,172 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -
          - - - {% if task_data and task_data.get("ParameterizedJob") %} -
          - Configuration - {% if task_data.ParameterizedJob %} - {% set optional = task_data.ParameterizedJob.get("MetaOptional", []) %} - {% set required = task_data.ParameterizedJob.get("MetaRequired", []) %} - {% else %} - {% set optional = [] %} - {% set required = [] %} - {% endif %} - {% set meta_defaults = task_data.get("Meta",{}) %} - {% for item in required %} -
          - - {% if item == "target" %} - {% include "tasks/partials/select-host.html.j2" %} - {% else %} - - {% endif %} -
          - {% endfor %} - {% for item in optional %} -
          - - {% if item == "target" %} - {% include "tasks/partials/select-host.html.j2" %} - {% else %} - - {% endif %} -
          - {% endfor %} - {% set nomad_payload_rule = task_data["ParameterizedJob"].get("Payload", "optional") %} - {% if nomad_payload_rule in ["optional", "required"] %} -
          - - -
          - {% endif %} -
          - {% endif %} - {% with chain_task_name=task.name, chain_form_id=task.name ~ "-send" %} - {% include "tasks/partials/chain-builder.html" %} - {% endwith %} -
          - - - - - {% if task.incremental_backup_url %} - - {% endif %} - {% if not task.protected %} - - {% endif %} -
          -
          -
          - -
          -
          - -
          -{% if task.incremental_backup_url %} -
          - -
          -{% endif %} -
          - -
          -
          - {% include "tasks/partials/scheduled-execution-dialog.html.j2" %} -
          -
          -
          diff --git a/templates/tasks/partials/run-python-form-restore_mongo.html.j2 b/templates/tasks/partials/run-python-form-restore_mongo.html.j2 deleted file mode 100644 index b1cf655032..0000000000 --- a/templates/tasks/partials/run-python-form-restore_mongo.html.j2 +++ /dev/null @@ -1,183 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -
          - - - {% if task_data and task_data.get("ParameterizedJob") %} -
          - Configuration - {% if task_data.ParameterizedJob %} - {% set optional = task_data.ParameterizedJob.get("MetaOptional", []) %} - {% set required = task_data.ParameterizedJob.get("MetaRequired", []) %} - {% else %} - {% set optional = [] %} - {% set required = [] %} - {% endif %} - {% set meta_defaults = task_data.get("Meta",{}) %} - {% for item in required %} -
          - - {% if item == "target" %} - {% include "tasks/partials/select-host.html.j2" %} - {% else %} - - {% endif %} -
          - {% endfor %} - {% for item in optional %} -
          - - {% if item == "target" %} - {% include "tasks/partials/select-host.html.j2" %} - {% else %} - - {% endif %} -
          - {% endfor %} - {% set nomad_payload_rule = task_data["ParameterizedJob"].get("Payload", "optional") %} - {% if nomad_payload_rule in ["optional", "required"] %} -
          - - -
          - {% endif %} -
          - {% endif %} - {% with chain_task_name=task.name, chain_form_id=task.name ~ "-send" %} - {% include "tasks/partials/chain-builder.html" %} - {% endwith %} -
          - - - {% if task.restore_url %} - - {% endif %} - {% if task.pbm_list_url %} - - {% endif %} - {% if task.force_resync_url %} - - {% endif %} - {% if not task.protected %} - - {% endif %} -
          -
          -{% if task.restore_url %} -
          - -
          -{% endif %} -{% if task.pbm_list_url %} -
          - -
          -{% endif %} -{% if task.force_resync_url %} -
          - -
          -{% endif %} -
          - -
          -
          - {% include "tasks/partials/scheduled-execution-dialog.html.j2" %} -
          -
          -
          diff --git a/templates/tasks/partials/run-python-form.html.j2 b/templates/tasks/partials/run-python-form.html.j2 deleted file mode 100644 index 0b921301ef..0000000000 --- a/templates/tasks/partials/run-python-form.html.j2 +++ /dev/null @@ -1,142 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -
          - - - {% if task_data and task_data.get("ParameterizedJob") %} -
          - Configuration - {% if task_data.ParameterizedJob %} - {% set optional = task_data.ParameterizedJob.get("MetaOptional", []) %} - {% set required = task_data.ParameterizedJob.get("MetaRequired", []) %} - {% else %} - {% set optional = [] %} - {% set required = [] %} - {% endif %} - {% set meta_defaults = task_data.get("Meta",{}) %} - {% for item in required %} -
          - - {% if item == "target" %} - {% include "tasks/partials/select-host.html.j2" %} - {% else %} - - {% endif %} -
          - {% endfor %} - {% for item in optional %} -
          - - {% if item == "target" %} - {% include "tasks/partials/select-host.html.j2" %} - {% else %} - - {% endif %} -
          - {% endfor %} - {% set nomad_payload_rule = task_data["ParameterizedJob"].get("Payload", "optional") %} - {% if nomad_payload_rule in ["optional", "required"] %} -
          - - -
          - {% endif %} -
          - {% endif %} - {% with chain_task_name=task.name, chain_form_id=task.name ~ "-send" %} - {% include "tasks/partials/chain-builder.html" %} - {% endwith %} -
          - - - {% if task.is_edit_enabled %} - {% if task.is_edit_form_present is not defined or task.is_edit_form_present %} - - {% endif %} - - {% endif %} -
          -
          -
          - -
          -
          - {% include "tasks/partials/scheduled-execution-dialog.html.j2" %} -
          -
          -
          diff --git a/templates/tasks/partials/running-tasks.html.j2 b/templates/tasks/partials/running-tasks.html.j2 deleted file mode 100644 index ff9caabefa..0000000000 --- a/templates/tasks/partials/running-tasks.html.j2 +++ /dev/null @@ -1,132 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -
          - - - - - - - - - - - - - {% if running_tasks %} - {% for task in running_tasks %} - {% set task_name = task["task"]["name"] %} - - - - - - - - - {% endfor %} - {% else %} - - - - {% endif %} - -
          NameHostChainStart AtExecuted byActions
          {{ task_name }}{{ task['execution_request']['target'] }} - {% set er = task.get('execution_request') or {} %} - {% set chain = er.get('meta', {}).get('_chain_task_names', []) %} - {% if chain %} - {% with chain_names=chain %} - {% include "tasks/partials/chain-display.html" %} - {% endwith %} - {% else %} - — - {% endif %} - {{ task['updated_at'] }}{{ user_id_to_username.get(task.executed_by, task.executed_by) or '' }} - -
          - - -
          -
          - -
          -
          - -
          - -
          -
          -
          -
          -
          -
          - -
          -
          -
          -
          -
          No tasks running
          -
          diff --git a/templates/tasks/partials/saved-tasks-actions-column-alters.html.j2 b/templates/tasks/partials/saved-tasks-actions-column-alters.html.j2 deleted file mode 100644 index c1588b06ab..0000000000 --- a/templates/tasks/partials/saved-tasks-actions-column-alters.html.j2 +++ /dev/null @@ -1,130 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} - - -{# - -
          - - - - -
          -
          -
          - -
          -
          - - -
          -
          - #} diff --git a/templates/tasks/partials/saved-tasks-actions-column.html.j2 b/templates/tasks/partials/saved-tasks-actions-column.html.j2 deleted file mode 100644 index d51a9408f1..0000000000 --- a/templates/tasks/partials/saved-tasks-actions-column.html.j2 +++ /dev/null @@ -1,95 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} - - diff --git a/templates/tasks/partials/scheduled-execution-dialog.html.j2 b/templates/tasks/partials/scheduled-execution-dialog.html.j2 deleted file mode 100644 index 12d24b4543..0000000000 --- a/templates/tasks/partials/scheduled-execution-dialog.html.j2 +++ /dev/null @@ -1,45 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} - -
          -

          Schedule task for execution

          -
          -
          -

          - Choose a specific point in time for when you the task to execute - automatically, and then click to Schedule. -

          - - - - -
          -
          - - -
          -
          - diff --git a/templates/tasks/partials/scheduled-tasks.html.j2 b/templates/tasks/partials/scheduled-tasks.html.j2 deleted file mode 100644 index 994eb41813..0000000000 --- a/templates/tasks/partials/scheduled-tasks.html.j2 +++ /dev/null @@ -1,335 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -
          - -
          -{% for periodic_task in periodic_tasks %} - -{% endfor %} - - - - - - - - - - - - - - - - {% for periodic_task in periodic_tasks %} - - - {% if periodic_task.crontab %} - {# djlint:off H025 #} - - - - - - - - - - - - - - - - - - - - - - {% endfor %} - - - - - - - - - - - - - - - - - - - -
          TaskPeriodStart TimeLast Run AtNext ExecutionRunsChain withEnabledActions
          - {%- if detail_route -%} - {{ periodic_task.task }} - {%- else -%} - {{ periodic_task.task }} - {%- endif -%} - - {% else %} - - {% endif %} - {{ periodic_task.period }} - {{ periodic_task.start_time or '' }}{{ periodic_task["last_run_at"] or '' }}{{ periodic_task.next_run_at or '' }}{{ periodic_task.total_run_count }} - {% if periodic_task.execute_request and periodic_task.execute_request.chain_task_names %} - {% with chain_names=periodic_task.execute_request.chain_task_names %} - {% include "tasks/partials/chain-display.html" %} - {% endwith %} - {% else %} - — - {% endif %} - -
          - - - -
          -
          - -
          - - -
          -
          - - -
          - - -
          - - change to - cron mode -
          -
          - - -
          -
          0 - {% if chainable_tasks is defined and chainable_tasks %} - {% with chain_task_name="", chain_form_id="new-periodic-task-form", compact=true, chain_input_name="execute_request_chain_task_names" %} - {% include "tasks/partials/chain-builder.html" %} - {% endwith %} - {% else %} - — - {% endif %} - - - - - - -
          - -
          diff --git a/templates/tasks/partials/select-host.html.j2 b/templates/tasks/partials/select-host.html.j2 deleted file mode 100644 index 500963cfec..0000000000 --- a/templates/tasks/partials/select-host.html.j2 +++ /dev/null @@ -1,27 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% if executor_hosts|length == 1 %} - {% set single_host = executor_hosts[0] %} - - -{% else %} - -{% endif %} diff --git a/templates/tasks/view.html.j2 b/templates/tasks/view.html.j2 deleted file mode 100644 index b5e481bef0..0000000000 --- a/templates/tasks/view.html.j2 +++ /dev/null @@ -1,123 +0,0 @@ -{# -Copyright (C) 2026 Percona LLC - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -#} -{% extends "base.html.j2" %} -{% block style %} - - - {% if history or running_tasks %} - - {% endif %} - - - - -{% endblock style %} -{% block behaviours_optimized_extra %} - - {% if history or running_tasks %} - - {% endif %} - - - - - - - -{% endblock behaviours_optimized_extra %} -{% block title -%} - Task: {{ task.name }} -{%- endblock title %} -{## TODO: the template is currently Nomad-specific#} -{% block main %} -
          -
          Engine
          -
          - {{ Nomad }} -
          -
          Created
          -
          - {{ task.created_at }} -
          - {% if task.created_by %} -
          Created by
          -
          - {{ user_id_to_username.get(task.created_by, task.created_by) or '' }} -
          - {% endif %} - {% if task.last_updated_by %} -
          Last modified by
          -
          - {{ user_id_to_username.get(task.last_updated_by, task.last_updated_by) or '' }} -
          - {% endif %} -
          Specification
          -
          - - {{ task }} - -
          -
          Available to
          -
          -
            -
          • {{ task.owner.capitalize() }}
          • -
          -
          -
          - {% if not task.is_template %} - {% if running_tasks %} -
          -

          Running

          - {% include "tasks/partials/running-tasks.html.j2" %} -
          - {% endif %} -
          -

          History

          - {% set history_tasks = history %} - {% include "tasks/partials/completed-tasks.html.j2" %} -
          - {% endif %} -{% endblock main %} diff --git a/tests/app/conftest.py b/tests/app/conftest.py index 5f83e6f068..cb1053d2d8 100644 --- a/tests/app/conftest.py +++ b/tests/app/conftest.py @@ -17,7 +17,6 @@ import inspect import os -from collections import OrderedDict from collections.abc import AsyncGenerator, Iterator from typing import Any from unittest.mock import AsyncMock, Mock @@ -50,17 +49,14 @@ from app.inventory.models import ServiceTypeEnum from app.sep.config import sep_settings from app.sep.deps import ( - get_api_authenticated_user, get_current_user, get_inventory_api, get_session, get_tasks_api, require_bearer_for_unsafe_methods, - validate_csrf, ) from app.sep.inventory import CreatedNode, CreatedSchema, CreatedService, CreatedTable from app.sep.main import sep_app -from app.sep.middleware.messages.config import messages_settings from app.sep.snippets.config import snippets_settings from app.tasks.anonymizer.config import anonymizer_settings from app.tasks.config import tasks_settings @@ -119,7 +115,6 @@ def _override_snapshot_cleared() -> None: sep_settings._set_snapshot({}) # noqa: SLF001 tasks_settings._set_snapshot({}) # noqa: SLF001 snippets_settings._set_snapshot({}) # noqa: SLF001 - messages_settings._set_snapshot({}) # noqa: SLF001 alert_settings._set_snapshot({}) # noqa: SLF001 anonymizer_settings._set_snapshot({}) # noqa: SLF001 @@ -577,10 +572,8 @@ def test_client(regular_user: CasdoorUser, session: AsyncSession) -> TestClient: DB. Tests that exercise the disabled path override ``get_session`` again with a session that carries an ``enabled=False`` row. """ - sep_app.dependency_overrides[validate_csrf] = lambda: True sep_app.dependency_overrides[require_bearer_for_unsafe_methods] = lambda: None sep_app.dependency_overrides[get_current_user] = lambda: regular_user - sep_app.dependency_overrides[get_api_authenticated_user] = lambda: regular_user sep_app.dependency_overrides[get_session] = lambda: session yield TestClient(sep_app, raise_server_exceptions=False) sep_app.dependency_overrides = {} @@ -595,9 +588,7 @@ def api_admin_client_no_bearer(admin_user: CasdoorUser) -> TestClient: JSON mutations to ``/api/apps/*`` are rejected by the framework Bearer gate. Use in tests that assert the 401 path. """ - sep_app.dependency_overrides[validate_csrf] = lambda: True sep_app.dependency_overrides[get_current_user] = lambda: admin_user - sep_app.dependency_overrides[get_api_authenticated_user] = lambda: admin_user yield TestClient(sep_app, raise_server_exceptions=False) sep_app.dependency_overrides = {} @@ -619,10 +610,8 @@ async def async_test_client(regular_user: CasdoorUser) -> AsyncClient: See :func:`test_client` for the Bearer-gate override rationale. """ - sep_app.dependency_overrides[validate_csrf] = lambda: True sep_app.dependency_overrides[require_bearer_for_unsafe_methods] = lambda: None sep_app.dependency_overrides[get_current_user] = lambda: regular_user - sep_app.dependency_overrides[get_api_authenticated_user] = lambda: regular_user transport = ASGITransport(app=sep_app) async with AsyncClient(transport=transport, base_url="http://test") as client: @@ -633,11 +622,9 @@ async def async_test_client(regular_user: CasdoorUser) -> AsyncClient: @pytest.fixture def dummy_request() -> Request: - """Create a dummy Request with a messages attribute in its state.""" + """Create a dummy Request for dependencies that take one.""" scope = {"type": "http", "headers": [], "client": ("127.0.0.1", "80"), "path": "/"} - req = Request(scope) - req.state.messages = OrderedDict() - return req + return Request(scope) @pytest.fixture diff --git a/tests/app/core/settings_override/api/test_app_owned_groups.py b/tests/app/core/settings_override/api/test_app_owned_groups.py index 3fcf0fc735..e77d181dc6 100644 --- a/tests/app/core/settings_override/api/test_app_owned_groups.py +++ b/tests/app/core/settings_override/api/test_app_owned_groups.py @@ -36,7 +36,7 @@ ) from app.core.settings_override.models import SettingClassEnum from app.core.utils import json_serializer -from app.sep.middleware.messages.config import messages_settings, MessagesSettings +from app.sep.snippets.config import snippets_settings, SnippetsSettings LIST_URL = "/settings/" @@ -89,9 +89,9 @@ def allow_admin() -> None: core_classes: list[ClassEntry] = [ ( - SettingClassEnum.MESSAGES_SETTINGS, - MessagesSettings, - messages_settings, + SettingClassEnum.SNIPPETS_SETTINGS, + SnippetsSettings, + snippets_settings, ), ] app_owned: list[AppOwnedClassEntry] = [ @@ -132,9 +132,9 @@ def allow_admin() -> None: router = build_settings_router( classes=[ ( - SettingClassEnum.MESSAGES_SETTINGS, - MessagesSettings, - messages_settings, + SettingClassEnum.SNIPPETS_SETTINGS, + SnippetsSettings, + snippets_settings, ), ], session_dep=session_dep, @@ -156,10 +156,10 @@ def test_app_owned_list_group_carries_metadata( assert response.status_code == status.HTTP_200_OK groups = {group["setting_class"]: group for group in response.json()["groups"]} assert set(groups) == { - SettingClassEnum.MESSAGES_SETTINGS.value, + SettingClassEnum.SNIPPETS_SETTINGS.value, SettingClassEnum.ALERT_SETTINGS.value, } - core = groups[SettingClassEnum.MESSAGES_SETTINGS.value] + core = groups[SettingClassEnum.SNIPPETS_SETTINGS.value] assert core["is_app_owned"] is False assert core["app_id"] is None @@ -176,7 +176,7 @@ def test_router_without_app_owned_omits_metadata( response = core_only_client.get(LIST_URL) assert response.status_code == status.HTTP_200_OK group = response.json()["groups"][0] - assert group["setting_class"] == SettingClassEnum.MESSAGES_SETTINGS.value + assert group["setting_class"] == SettingClassEnum.SNIPPETS_SETTINGS.value assert group["is_app_owned"] is False assert group["app_id"] is None assert group["app_display_name"] is None diff --git a/tests/app/core/settings_override/test_cache.py b/tests/app/core/settings_override/test_cache.py index ae050d9a69..599c808473 100644 --- a/tests/app/core/settings_override/test_cache.py +++ b/tests/app/core/settings_override/test_cache.py @@ -359,16 +359,18 @@ async def test_nested_override_appears_as_model_copy_under_top_level_key( await _insert( session, setting_class=SettingClassEnum.SEP_SETTINGS, - key="SESSION__MAX_AGE", + key="SESSION_REFRESH__MAX_AGE", value=3600, ) snapshot = await build_snapshot(session, SEPSettings) - merged = snapshot["SESSION"] + merged = snapshot["SESSION_REFRESH"] assert isinstance(merged, SessionOptions) assert timedelta(seconds=3600) == merged.MAX_AGE - # Untouched fields match the default ``SessionOptions``. - assert merged.COOKIE_NAME == SessionOptions().COOKIE_NAME - assert merged.SAMESITE == SessionOptions().SAMESITE + # Untouched leaves keep the field's own declared default, which is not the + # bare ``SessionOptions()`` default (``SESSION_REFRESH`` pins its own). + declared_default = SEPSettings.model_fields["SESSION_REFRESH"].default + assert merged.COOKIE_NAME == declared_default.COOKIE_NAME + assert merged.SAMESITE == declared_default.SAMESITE @pytest.mark.asyncio @@ -376,15 +378,15 @@ async def test_nested_override_merges_onto_base_settings_value( session: AsyncSession, ) -> None: """Leaves with no override row fall back to the YAML/env base value (AC #3).""" - base = SimpleNamespace(SESSION=SessionOptions(SAMESITE="strict")) + base = SimpleNamespace(SESSION_REFRESH=SessionOptions(SAMESITE="strict")) await _insert( session, setting_class=SettingClassEnum.SEP_SETTINGS, - key="SESSION__MAX_AGE", + key="SESSION_REFRESH__MAX_AGE", value=3600, ) snapshot = await build_snapshot(session, SEPSettings, base_settings=base) - merged = snapshot["SESSION"] + merged = snapshot["SESSION_REFRESH"] assert timedelta(seconds=3600) == merged.MAX_AGE # The non-overridden SAMESITE keeps the base (YAML/env) value, not the default. assert merged.SAMESITE == "strict" @@ -404,17 +406,17 @@ async def test_mixed_case_sibling_rows_merge_into_one_parent( await _insert( session, setting_class=SettingClassEnum.SEP_SETTINGS, - key="session__max_age", + key="session_refresh__max_age", value=3600, ) await _insert( session, setting_class=SettingClassEnum.SEP_SETTINGS, - key="SESSION__SAMESITE", + key="SESSION_REFRESH__SAMESITE", value="strict", ) snapshot = await build_snapshot(session, SEPSettings) - merged = snapshot["SESSION"] + merged = snapshot["SESSION_REFRESH"] assert timedelta(seconds=3600) == merged.MAX_AGE assert merged.SAMESITE == "strict" @@ -434,17 +436,17 @@ async def test_duplicate_canonical_leaf_keeps_newest_row( await _insert( session, setting_class=SettingClassEnum.SEP_SETTINGS, - key="session__max_age", + key="session_refresh__max_age", value=3600, ) await _insert( session, setting_class=SettingClassEnum.SEP_SETTINGS, - key="SESSION__MAX_AGE", + key="SESSION_REFRESH__MAX_AGE", value=7200, ) snapshot = await build_snapshot(session, SEPSettings) - assert timedelta(seconds=7200) == snapshot["SESSION"].MAX_AGE + assert timedelta(seconds=7200) == snapshot["SESSION_REFRESH"].MAX_AGE @pytest.mark.asyncio @@ -516,11 +518,11 @@ async def test_unknown_nested_leaf_skipped_with_warning( await _insert( session, setting_class=SettingClassEnum.SEP_SETTINGS, - key="SESSION__BOGUS_FIELD", + key="SESSION_REFRESH__BOGUS_FIELD", value=1, ) snapshot = await build_snapshot(session, SEPSettings) - assert "SESSION" not in snapshot + assert "SESSION_REFRESH" not in snapshot assert any( "unknown or not-overridable field" in r.getMessage() for r in caplog.records ) @@ -534,17 +536,17 @@ async def test_nested_coercion_failure_skips_only_failing_leaf( await _insert( session, setting_class=SettingClassEnum.SEP_SETTINGS, - key="SESSION__MAX_AGE", + key="SESSION_REFRESH__MAX_AGE", value="not-a-number", ) await _insert( session, setting_class=SettingClassEnum.SEP_SETTINGS, - key="SESSION__SAMESITE", + key="SESSION_REFRESH__SAMESITE", value="strict", ) snapshot = await build_snapshot(session, SEPSettings) - merged = snapshot["SESSION"] + merged = snapshot["SESSION_REFRESH"] assert merged.SAMESITE == "strict" # The failed MAX_AGE leaf keeps the default. assert merged.MAX_AGE == SessionOptions().MAX_AGE @@ -559,17 +561,17 @@ async def test_top_level_row_targeting_nested_only_parent_is_skipped( await _insert( session, setting_class=SettingClassEnum.SEP_SETTINGS, - key="SESSION", + key="SESSION_REFRESH", value={"MAX_AGE": 10, "SAMESITE": "none"}, ) await _insert( session, setting_class=SettingClassEnum.SEP_SETTINGS, - key="SESSION__MAX_AGE", + key="SESSION_REFRESH__MAX_AGE", value=3600, ) snapshot = await build_snapshot(session, SEPSettings) - merged = snapshot["SESSION"] + merged = snapshot["SESSION_REFRESH"] assert timedelta(seconds=3600) == merged.MAX_AGE # The whole-object row did not take effect (SAMESITE stays default). assert merged.SAMESITE == SessionOptions().SAMESITE @@ -665,16 +667,18 @@ async def test_snapshot_refresh_replaces_merged_copy(session: AsyncSession) -> N await _insert( session, setting_class=SettingClassEnum.SEP_SETTINGS, - key="SESSION__MAX_AGE", + key="SESSION_REFRESH__MAX_AGE", value=100, ) first = await build_snapshot(session, SEPSettings) - assert "SESSION" in first + assert "SESSION_REFRESH" in first await SettingsOverrideManager.delete_where( - session, setting_class=SettingClassEnum.SEP_SETTINGS, key="SESSION__MAX_AGE" + session, + setting_class=SettingClassEnum.SEP_SETTINGS, + key="SESSION_REFRESH__MAX_AGE", ) second = await build_snapshot(session, SEPSettings) - assert "SESSION" not in second + assert "SESSION_REFRESH" not in second class _Derived(BaseModel): @@ -783,15 +787,18 @@ def test_parent_base_value_prefers_whole_object_snapshot_entry() -> None: silently reverted. """ whole_object = SessionOptions(MAX_AGE=timedelta(seconds=111)) - field_info = SEPSettings.model_fields["SESSION"] + field_info = SEPSettings.model_fields["SESSION_REFRESH"] result = _parent_base_value( - {"SESSION": whole_object}, field_info, "SESSION", base_settings=None + {"SESSION_REFRESH": whole_object}, + field_info, + "SESSION_REFRESH", + base_settings=None, ) assert result is whole_object def test_parent_base_value_falls_back_to_field_default() -> None: """With no snapshot entry and no base settings, the field default seeds the merge.""" - field_info = SEPSettings.model_fields["SESSION"] - result = _parent_base_value({}, field_info, "SESSION", base_settings=None) + field_info = SEPSettings.model_fields["SESSION_REFRESH"] + result = _parent_base_value({}, field_info, "SESSION_REFRESH", base_settings=None) assert isinstance(result, SessionOptions) diff --git a/tests/app/core/settings_override/test_policy.py b/tests/app/core/settings_override/test_policy.py index fe15b3389d..84c38ff7b6 100644 --- a/tests/app/core/settings_override/test_policy.py +++ b/tests/app/core/settings_override/test_policy.py @@ -53,7 +53,6 @@ from app.inventory.config import InventorySettings from app.sep.apps.alerts.config import AlertsSettings from app.sep.config import SEPSettings -from app.sep.middleware.messages.config import MessagesSettings from app.sep.snippets.config import SnippetsSettings from app.tasks.anonymizer.config import AnonymizerSettings from app.tasks.config import TasksSettings @@ -66,7 +65,6 @@ SettingClassEnum.SEP_SETTINGS: SEPSettings, SettingClassEnum.TASKS_SETTINGS: TasksSettings, SettingClassEnum.SNIPPETS_SETTINGS: SnippetsSettings, - SettingClassEnum.MESSAGES_SETTINGS: MessagesSettings, SettingClassEnum.ALERT_SETTINGS: AlertSettings, SettingClassEnum.ALERTS_SETTINGS: AlertsSettings, SettingClassEnum.ANONYMIZER_SETTINGS: AnonymizerSettings, diff --git a/tests/app/core/settings_override/test_registry.py b/tests/app/core/settings_override/test_registry.py index a0c1044930..ca55840adb 100644 --- a/tests/app/core/settings_override/test_registry.py +++ b/tests/app/core/settings_override/test_registry.py @@ -50,7 +50,6 @@ from app.core.utils.pydantic import field_with_metadata from app.inventory.config import InventorySettings from app.sep.config import SEPSettings -from app.sep.middleware.messages.config import MessagesSettings from app.sep.snippets.config import SnippetsSettings from app.tasks.config import TasksSettings @@ -503,9 +502,9 @@ def test_hot_field_names_tasks_settings() -> None: def test_nested_overridable_field_names_sep_settings() -> None: - """Assert ``SEPSettings`` exposes the session parents plus ``APP_DRAIN``.""" + """Assert ``SEPSettings`` exposes the refresh-session parent plus ``APP_DRAIN``.""" assert nested_overridable_field_names(SEPSettings) == frozenset( - {"SESSION", "SESSION_REFRESH", "APP_DRAIN"} + {"SESSION_REFRESH", "APP_DRAIN"} ) @@ -531,11 +530,6 @@ def test_hot_field_names_snippets_settings() -> None: ) -def test_hot_field_names_messages_settings() -> None: - """Assert ``MessagesSettings`` ships ``LEVEL`` as its single HOT field.""" - assert hot_field_names(MessagesSettings) == frozenset({"LEVEL"}) - - def test_hot_field_names_inventory_settings_empty() -> None: """Assert ``InventorySettings`` has no HOT fields in this iteration.""" assert hot_field_names(InventorySettings) == frozenset() @@ -568,7 +562,6 @@ def test_reload_classification_values() -> None: @pytest.mark.parametrize( "field_name", [ - "SESSION", "SESSION_REFRESH", "INVENTORY_ENDPOINT", "TASKS_ENDPOINT", @@ -618,8 +611,8 @@ def test_tasks_settings_marked_advanced(field_name: str) -> None: def test_session_leaf_inherits_advanced() -> None: - """Assert every ``SESSION`` leaf inherits the parent's advanced flag.""" - leaf = resolve_nested_field_metadata(SEPSettings, "SESSION__COOKIE_NAME") + """Assert every ``SESSION_REFRESH`` leaf inherits the parent's advanced flag.""" + leaf = resolve_nested_field_metadata(SEPSettings, "SESSION_REFRESH__COOKIE_NAME") assert leaf is not None assert leaf.is_advanced is True diff --git a/tests/app/core/settings_override/test_registry_nested.py b/tests/app/core/settings_override/test_registry_nested.py index be66ea394a..8cc08d2a8a 100644 --- a/tests/app/core/settings_override/test_registry_nested.py +++ b/tests/app/core/settings_override/test_registry_nested.py @@ -118,10 +118,10 @@ def test_resolve_field_in_model_missing_segment() -> None: def test_resolve_nested_field_single_level() -> None: """A single-level key resolves to a one-segment chain and its leaf field.""" - resolved = resolve_nested_field(SEPSettings, "SESSION__MAX_AGE") + resolved = resolve_nested_field(SEPSettings, "SESSION_REFRESH__MAX_AGE") assert resolved is not None chain, _ = resolved - assert chain == ("SESSION", "MAX_AGE") + assert chain == ("SESSION_REFRESH", "MAX_AGE") def test_resolve_nested_field_multi_level() -> None: @@ -141,7 +141,7 @@ def test_resolve_nested_field_unknown_top_level() -> None: def test_resolve_nested_field_unknown_nested_leaf() -> None: """An unknown nested leaf resolves to ``None``.""" - assert resolve_nested_field(SEPSettings, "SESSION__BOGUS") is None + assert resolve_nested_field(SEPSettings, "SESSION_REFRESH__BOGUS") is None def test_resolve_nested_field_non_pydantic_intermediate() -> None: @@ -151,7 +151,7 @@ def test_resolve_nested_field_non_pydantic_intermediate() -> None: def test_resolve_nested_field_primitive_past_leaf() -> None: """A path that descends past a primitive leaf resolves to ``None``.""" - assert resolve_nested_field(SEPSettings, "SESSION__MAX_AGE__SUB") is None + assert resolve_nested_field(SEPSettings, "SESSION_REFRESH__MAX_AGE__SUB") is None def test_resolve_nested_field_empty_key() -> None: @@ -161,8 +161,10 @@ def test_resolve_nested_field_empty_key() -> None: def test_coerce_nested_field_value_success() -> None: """A leaf value is coerced to the leaf's declared type (int → timedelta).""" - chain, value = coerce_nested_field_value(SEPSettings, "SESSION__MAX_AGE", 3600) - assert chain == ("SESSION", "MAX_AGE") + chain, value = coerce_nested_field_value( + SEPSettings, "SESSION_REFRESH__MAX_AGE", 3600 + ) + assert chain == ("SESSION_REFRESH", "MAX_AGE") assert value == timedelta(seconds=3600) @@ -179,7 +181,7 @@ def test_coerce_nested_field_value_preserves_constraint() -> None: def test_coerce_nested_field_value_unresolvable_raises_keyerror() -> None: """An unresolvable path raises ``KeyError``.""" with pytest.raises(KeyError): - coerce_nested_field_value(SEPSettings, "SESSION__BOGUS", 1) + coerce_nested_field_value(SEPSettings, "SESSION_REFRESH__BOGUS", 1) def test_coerce_nested_field_value_rejects_not_overridable_leaf() -> None: @@ -256,14 +258,14 @@ def test_is_nested_overridable_parent_false_for_missing_field() -> None: def test_iter_nested_leaf_keys_session_yields_all_leaves() -> None: - """``SESSION`` enumerates its five leaves with canonical uppercase chains.""" - leaves = dict(iter_nested_leaf_keys(SEPSettings, "SESSION")) + """``SESSION_REFRESH`` enumerates its five leaves with canonical uppercase chains.""" + leaves = dict(iter_nested_leaf_keys(SEPSettings, "SESSION_REFRESH")) assert set(leaves) == { - "SESSION__COOKIE_NAME", - "SESSION__MAX_AGE", - "SESSION__SAMESITE", - "SESSION__SECURE", - "SESSION__PATH", + "SESSION_REFRESH__COOKIE_NAME", + "SESSION_REFRESH__MAX_AGE", + "SESSION_REFRESH__SAMESITE", + "SESSION_REFRESH__SECURE", + "SESSION_REFRESH__PATH", } for key, chain in leaves.items(): assert "__".join(chain) == key @@ -536,6 +538,6 @@ def test_rendered_leaf_keys_keeps_allowlist_withheld_leaves( the key is addressable at all rather than whether the policy permits it. """ restrict("Settings.LOGGING") - assert dict(rendered_leaf_keys(SEPSettings, "SESSION")) == dict( - iter_nested_leaf_keys(SEPSettings, "SESSION") + assert dict(rendered_leaf_keys(SEPSettings, "SESSION_REFRESH")) == dict( + iter_nested_leaf_keys(SEPSettings, "SESSION_REFRESH") ) diff --git a/tests/app/core/test_config.py b/tests/app/core/test_config.py index 020972f2d2..63c84691fe 100644 --- a/tests/app/core/test_config.py +++ b/tests/app/core/test_config.py @@ -54,8 +54,6 @@ from app.sep.apps.alerts.config import AlertsSettings from app.sep.apps.atw.config import AtwSettings from app.sep.config import SEPSettings -from app.sep.middleware.messages.config import MessagesSettings -from app.sep.middleware.messages.models import MessageLevel from app.sep.snippets.config import SnippetsSettings from app.tasks.anonymizer.config import AnonymizerSettings from app.tasks.config import TasksSettings @@ -514,14 +512,6 @@ def test_empty_secret_key_without_token_raises(self): lambda s: s.bundle_ttl, id="AtwSettings", ), - pytest.param( - MessagesSettings, - "SEP__MESSAGES__LEVEL", - str(MessageLevel.ERROR.value), - MessageLevel.ERROR, - lambda s: s.LEVEL, - id="MessagesSettings", - ), pytest.param( SnippetsSettings, "SEP__SNIPPETS__PREVIEW_MAX_CHARS", diff --git a/tests/app/migrations/test_shared_postgres_settingoverride.py b/tests/app/migrations/test_shared_postgres_settingoverride.py index a41efedc29..3ec8aa6059 100644 --- a/tests/app/migrations/test_shared_postgres_settingoverride.py +++ b/tests/app/migrations/test_shared_postgres_settingoverride.py @@ -69,7 +69,6 @@ "SEP_SETTINGS", "TASKS_SETTINGS", "SNIPPETS_SETTINGS", - "MESSAGES_SETTINGS", "SETTINGS", "ALERT_SETTINGS", "ANONYMIZER_SETTINGS", diff --git a/tests/app/sep/api/routes/test_app_state.py b/tests/app/sep/api/routes/test_app_state.py index aee3be7d26..0ff6c718d1 100644 --- a/tests/app/sep/api/routes/test_app_state.py +++ b/tests/app/sep/api/routes/test_app_state.py @@ -35,11 +35,9 @@ from app.sep.apps.framework.registry import get_app_registry from app.sep.crud import AppRunningTaskManager, AppStateManager from app.sep.deps import ( - get_api_authenticated_user, get_current_user, get_session, require_bearer_for_unsafe_methods, - validate_csrf, ) from app.sep.main import sep_app from app.sep.models import ( @@ -101,9 +99,7 @@ def api_admin_client_fixture( Overrides the celery-beat session too, so the toggle's periodic-task gate runs against the in-memory beat DB instead of the real scheduler database. """ - sep_app.dependency_overrides[validate_csrf] = lambda: True sep_app.dependency_overrides[get_current_user] = lambda: admin_user - sep_app.dependency_overrides[get_api_authenticated_user] = lambda: admin_user sep_app.dependency_overrides[get_session] = lambda: override_session sep_app.dependency_overrides[get_celery_beat_session] = lambda: celery_beat_session sep_app.dependency_overrides[require_bearer_for_unsafe_methods] = lambda: None @@ -116,9 +112,7 @@ def api_non_admin_client_fixture( regular_user: CasdoorUser, override_session: AsyncSession ) -> Iterator[TestClient]: """Yield a non-admin client with the in-memory SEP session.""" - sep_app.dependency_overrides[validate_csrf] = lambda: True sep_app.dependency_overrides[get_current_user] = lambda: regular_user - sep_app.dependency_overrides[get_api_authenticated_user] = lambda: regular_user sep_app.dependency_overrides[get_session] = lambda: override_session sep_app.dependency_overrides[require_bearer_for_unsafe_methods] = lambda: None yield TestClient(sep_app, raise_server_exceptions=False) @@ -130,9 +124,7 @@ def api_admin_cookie_client_fixture( admin_user: CasdoorUser, override_session: AsyncSession ) -> Iterator[TestClient]: """Yield a cookie-authenticated admin with the Bearer gate left intact.""" - sep_app.dependency_overrides[validate_csrf] = lambda: True sep_app.dependency_overrides[get_current_user] = lambda: admin_user - sep_app.dependency_overrides[get_api_authenticated_user] = lambda: admin_user sep_app.dependency_overrides[get_session] = lambda: override_session yield TestClient(sep_app, raise_server_exceptions=False) sep_app.dependency_overrides = {} diff --git a/tests/app/sep/api/routes/test_apps.py b/tests/app/sep/api/routes/test_apps.py index ddaf35f198..8c041afbec 100644 --- a/tests/app/sep/api/routes/test_apps.py +++ b/tests/app/sep/api/routes/test_apps.py @@ -32,10 +32,8 @@ from app.sep.apps.framework.base import BaseApp from app.sep.apps.framework.registry import AppRegistry, get_app_registry from app.sep.deps import ( - get_api_authenticated_user, get_current_user, get_session, - validate_csrf, ) from app.sep.main import sep_app from app.sep.models import AppLifecycleEnum, AppState @@ -85,9 +83,7 @@ def api_user_client_fixture( regular_user: CasdoorUser, override_session: AsyncSession ) -> Iterator[TestClient]: """Yield an authenticated (non-admin) client with the in-memory SEP session.""" - sep_app.dependency_overrides[validate_csrf] = lambda: True sep_app.dependency_overrides[get_current_user] = lambda: regular_user - sep_app.dependency_overrides[get_api_authenticated_user] = lambda: regular_user sep_app.dependency_overrides[get_session] = lambda: override_session yield TestClient(sep_app, raise_server_exceptions=False) sep_app.dependency_overrides = {} diff --git a/tests/app/sep/api/routes/test_connectivity_check.py b/tests/app/sep/api/routes/test_connectivity_check.py index f185f4e18c..93597008cc 100644 --- a/tests/app/sep/api/routes/test_connectivity_check.py +++ b/tests/app/sep/api/routes/test_connectivity_check.py @@ -36,7 +36,7 @@ from app.sep.clients.pmm import PMMRemoteAPI from app.sep.deps import ( get_api_authenticated_admin, - get_api_authenticated_user, + get_current_user, require_bearer_for_unsafe_methods, ) from app.sep.main import sep_app @@ -57,7 +57,7 @@ def _reachable(service: str, version: str | None = None) -> ConnectivityResult: @pytest.fixture def admin_client(admin_user: CasdoorUser) -> TestClient: """Yield an admin TestClient with the Bearer gate satisfied.""" - sep_app.dependency_overrides[get_api_authenticated_user] = lambda: admin_user + sep_app.dependency_overrides[get_current_user] = lambda: admin_user sep_app.dependency_overrides[get_api_authenticated_admin] = lambda: admin_user sep_app.dependency_overrides[require_bearer_for_unsafe_methods] = lambda: None yield TestClient(sep_app, raise_server_exceptions=False) diff --git a/tests/app/sep/api/routes/test_hosts.py b/tests/app/sep/api/routes/test_hosts.py index 400c77eded..c0f8dcd2a1 100644 --- a/tests/app/sep/api/routes/test_hosts.py +++ b/tests/app/sep/api/routes/test_hosts.py @@ -213,9 +213,8 @@ def test_unauthenticated_unknown_sep_path_returns_json_404( ) -> None: """Return JSON 404 for unknown ``/api/sep/*`` paths even when unauthenticated. - Exercise the ``JSON_API_PATH_PREFIXES`` tuple directly (not just the - registered route): the 404 handler must return JSON rather than an - HTML redirect to the login page for any path matching the prefix. + The 404 handler is unconditional now, so an unmatched path returns JSON + regardless of whether the caller is authenticated. """ response = unauthenticated_client.get( "/api/sep/does-not-exist/", follow_redirects=False diff --git a/tests/app/sep/api/routes/test_settings.py b/tests/app/sep/api/routes/test_settings.py index 805c5e3fd6..01e445e482 100644 --- a/tests/app/sep/api/routes/test_settings.py +++ b/tests/app/sep/api/routes/test_settings.py @@ -48,16 +48,13 @@ from app.sep.bundle_upload.plan import DeliveryPlan from app.sep.config import sep_settings, SEPSettings from app.sep.deps import ( - get_api_authenticated_user, get_current_user, get_session, get_tasks_api, require_bearer_for_unsafe_methods, TaskAPI, - validate_csrf, ) from app.sep.main import sep_app, sep_overrides_lifespan -from app.sep.middleware.messages.config import messages_settings from app.sep.models import AppLifecycleEnum, AppState from app.sep.snippets.config import ( SnippetFilter, @@ -147,9 +144,7 @@ def api_admin_client_fixture( admin_user: CasdoorUser, override_session: AsyncSession ) -> Iterator[TestClient]: """Yield an admin-authenticated SEP TestClient with the in-memory SEP session.""" - sep_app.dependency_overrides[validate_csrf] = lambda: True sep_app.dependency_overrides[get_current_user] = lambda: admin_user - sep_app.dependency_overrides[get_api_authenticated_user] = lambda: admin_user sep_app.dependency_overrides[get_session] = lambda: override_session sep_app.dependency_overrides[require_bearer_for_unsafe_methods] = lambda: None sep_app.dependency_overrides[get_tasks_api] = lambda: _mock_tasks_api() @@ -162,9 +157,7 @@ def api_non_admin_client_fixture( regular_user: CasdoorUser, override_session: AsyncSession ) -> Iterator[TestClient]: """Yield a non-admin SEP TestClient with the in-memory SEP session.""" - sep_app.dependency_overrides[validate_csrf] = lambda: True sep_app.dependency_overrides[get_current_user] = lambda: regular_user - sep_app.dependency_overrides[get_api_authenticated_user] = lambda: regular_user sep_app.dependency_overrides[get_session] = lambda: override_session sep_app.dependency_overrides[require_bearer_for_unsafe_methods] = lambda: None sep_app.dependency_overrides[get_tasks_api] = lambda: _mock_tasks_api() @@ -181,9 +174,7 @@ def api_admin_cookie_client_fixture( The Bearer guard runs as in production; mutations should reject the client with 401 while reads succeed. """ - sep_app.dependency_overrides[validate_csrf] = lambda: True sep_app.dependency_overrides[get_current_user] = lambda: admin_user - sep_app.dependency_overrides[get_api_authenticated_user] = lambda: admin_user sep_app.dependency_overrides[get_session] = lambda: override_session sep_app.dependency_overrides[get_tasks_api] = lambda: _mock_tasks_api() yield TestClient(sep_app, raise_server_exceptions=False) @@ -335,7 +326,6 @@ async def test_returns_local_proxied_and_app_owned_groups( assert groups == { SettingClassEnum.SEP_SETTINGS.value, SettingClassEnum.SNIPPETS_SETTINGS.value, - SettingClassEnum.MESSAGES_SETTINGS.value, SettingClassEnum.ALERTS_SETTINGS.value, SettingClassEnum.SETTINGS.value, SettingClassEnum.TASKS_SETTINGS.value, @@ -366,7 +356,6 @@ async def test_core_groups_are_not_app_owned( core_and_remote = { SettingClassEnum.SEP_SETTINGS.value, SettingClassEnum.SNIPPETS_SETTINGS.value, - SettingClassEnum.MESSAGES_SETTINGS.value, SettingClassEnum.ALERT_SETTINGS.value, SettingClassEnum.TASKS_SETTINGS.value, } @@ -443,9 +432,9 @@ async def test_lists_hot_and_not_overridable_entries( ) -> None: """Assert a SEPSettings group exposes HOT and NOT_OVERRIDABLE entries. - NESTED_ONLY parents (``SESSION`` / ``SESSION_REFRESH``) are expanded into - their per-leaf entries, each classified ``HOT``, so the LIST projection no - longer carries a ``NESTED_ONLY`` parent summary. + The NESTED_ONLY parent ``SESSION_REFRESH`` is expanded into its per-leaf + entries, each classified ``HOT``, so the LIST projection no longer carries + a ``NESTED_ONLY`` parent summary. """ response = api_admin_client.get("/api/sep/admin/settings/") assert response.status_code == status.HTTP_200_OK @@ -489,10 +478,10 @@ async def test_connectivity_check_default_advertises_false( ) assert sep_setting["default_value"] is False - async def test_session_parent_expanded_into_leaves( + async def test_session_refresh_parent_expanded_into_leaves( self, api_admin_client: TestClient ) -> None: - """Assert ``SESSION`` is replaced by one editable entry per leaf, no summary entry.""" + """Assert ``SESSION_REFRESH`` is replaced by one entry per leaf, no summary entry.""" response = api_admin_client.get("/api/sep/admin/settings/") sep_settings_group = next( group @@ -500,13 +489,13 @@ async def test_session_parent_expanded_into_leaves( if group["setting_class"] == SettingClassEnum.SEP_SETTINGS.value ) keys = {entry["key"] for entry in sep_settings_group["settings"]} - assert "SESSION" not in keys + assert "SESSION_REFRESH" not in keys expected_leaves = { - "SESSION__COOKIE_NAME", - "SESSION__MAX_AGE", - "SESSION__SAMESITE", - "SESSION__SECURE", - "SESSION__PATH", + "SESSION_REFRESH__COOKIE_NAME", + "SESSION_REFRESH__MAX_AGE", + "SESSION_REFRESH__SAMESITE", + "SESSION_REFRESH__SECURE", + "SESSION_REFRESH__PATH", } assert expected_leaves <= keys for entry in sep_settings_group["settings"]: @@ -568,11 +557,11 @@ async def test_nested_leaf_detail_carries_key_path( ) -> None: """Assert a nested-leaf DETAIL response carries its canonical ``key_path`` chain.""" response = api_admin_client.get( - "/api/sep/admin/settings/SEPSettings/SESSION__MAX_AGE" + "/api/sep/admin/settings/SEPSettings/SESSION_REFRESH__MAX_AGE" ) assert response.status_code == status.HTTP_200_OK body = response.json() - assert body["key_path"] == ["SESSION", "MAX_AGE"] + assert body["key_path"] == ["SESSION_REFRESH", "MAX_AGE"] assert "__".join(body["key_path"]) == body["key"] async def test_unknown_class_returns_422( @@ -1330,7 +1319,7 @@ async def test_delete_unknown_key_returns_404( @pytest.mark.asyncio class TestSepSettingsNestedOverrides: - """Tests for ``__``-delimited nested overrides on ``SEPSettings.SESSION``.""" + """Cover ``__``-delimited nested overrides on ``SEPSettings.SESSION_REFRESH``.""" @pytest.fixture(autouse=True) def _reset_proxy_snapshot(self) -> Iterator[None]: @@ -1344,19 +1333,21 @@ async def test_patch_nested_override_persists_and_marks_parent( """Persist a nested PATCH, echo the nested key, and mark the parent.""" response = api_admin_client.patch( "/api/sep/admin/settings/SEPSettings", - json={"SESSION__SAMESITE": "strict"}, + json={"SESSION_REFRESH__SAMESITE": "strict"}, ) assert response.status_code == status.HTTP_200_OK body = response.json() - assert body[0]["key"] == "SESSION__SAMESITE" + assert body[0]["key"] == "SESSION_REFRESH__SAMESITE" assert body[0]["value"] == "strict" assert body[0]["has_override"] is True # The parent reads back as having an override. - parent = api_admin_client.get("/api/sep/admin/settings/SEPSettings/SESSION") + parent = api_admin_client.get( + "/api/sep/admin/settings/SEPSettings/SESSION_REFRESH" + ) assert parent.json()["has_override"] is True # The nested leaf reads back its current value. leaf = api_admin_client.get( - "/api/sep/admin/settings/SEPSettings/SESSION__SAMESITE" + "/api/sep/admin/settings/SEPSettings/SESSION_REFRESH__SAMESITE" ) assert leaf.json()["value"] == "strict" @@ -1366,24 +1357,24 @@ async def test_patch_nested_echoes_key_path( """Echo the leaf's canonical ``key_path`` chain on a nested PATCH.""" response = api_admin_client.patch( "/api/sep/admin/settings/SEPSettings", - json={"SESSION__SAMESITE": "strict"}, + json={"SESSION_REFRESH__SAMESITE": "strict"}, ) assert response.status_code == status.HTTP_200_OK echoed = response.json()[0] - assert echoed["key_path"] == ["SESSION", "SAMESITE"] + assert echoed["key_path"] == ["SESSION_REFRESH", "SAMESITE"] assert "__".join(echoed["key_path"]) == echoed["key"] async def test_patch_nested_coerces_int_to_timedelta( self, api_admin_client: TestClient ) -> None: - """Coerce a JSON int on ``SESSION__MAX_AGE`` to a timedelta.""" + """Coerce a JSON int on ``SESSION_REFRESH__MAX_AGE`` to a timedelta.""" override_seconds = 7200 response = api_admin_client.patch( "/api/sep/admin/settings/SEPSettings", - json={"SESSION__MAX_AGE": override_seconds}, + json={"SESSION_REFRESH__MAX_AGE": override_seconds}, ) assert response.status_code == status.HTTP_200_OK - assert sep_settings.SESSION.MAX_AGE.total_seconds() == override_seconds + assert sep_settings.SESSION_REFRESH.MAX_AGE.total_seconds() == override_seconds async def test_patch_nested_rejects_unknown_nested_field( self, api_admin_client: TestClient @@ -1391,7 +1382,7 @@ async def test_patch_nested_rejects_unknown_nested_field( """Reject an unknown nested leaf with ``unknown_nested_field``.""" response = api_admin_client.patch( "/api/sep/admin/settings/SEPSettings", - json={"SESSION__BOGUS": 1}, + json={"SESSION_REFRESH__BOGUS": 1}, ) assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT detail = response.json()["detail"] @@ -1415,12 +1406,13 @@ async def test_patch_whole_parent_rejected_for_nested_only( """Reject replacing the whole NESTED_ONLY parent object.""" response = api_admin_client.patch( "/api/sep/admin/settings/SEPSettings", - json={"SESSION": {"MAX_AGE": 3600}}, + json={"SESSION_REFRESH": {"MAX_AGE": 3600}}, ) assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT detail = response.json()["detail"] assert any( - entry["type"] == "not_overridable" and entry["loc"] == ["body", "SESSION"] + entry["type"] == "not_overridable" + and entry["loc"] == ["body", "SESSION_REFRESH"] for entry in detail ) @@ -1429,7 +1421,7 @@ async def test_delete_whole_parent_rejected_for_nested_only( ) -> None: """Return 422 (not 404) on DELETE of the whole NESTED_ONLY parent.""" response = api_admin_client.delete( - "/api/sep/admin/settings/SEPSettings/SESSION" + "/api/sep/admin/settings/SEPSettings/SESSION_REFRESH" ) assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT detail = response.json()["detail"] @@ -1441,9 +1433,11 @@ async def test_get_whole_parent_returns_merged_value( """Allow GET on the whole parent and return the merged value.""" api_admin_client.patch( "/api/sep/admin/settings/SEPSettings", - json={"SESSION__SAMESITE": "strict"}, + json={"SESSION_REFRESH__SAMESITE": "strict"}, + ) + response = api_admin_client.get( + "/api/sep/admin/settings/SEPSettings/SESSION_REFRESH" ) - response = api_admin_client.get("/api/sep/admin/settings/SEPSettings/SESSION") assert response.status_code == status.HTTP_200_OK assert response.json()["value"]["SAMESITE"] == "strict" @@ -1452,24 +1446,24 @@ async def test_delete_nested_override_clears_merged_value( ) -> None: """Revert the leaf to its YAML/env value when deleting a nested override (AC #3).""" override_seconds = 7200 - original = sep_settings.SESSION.MAX_AGE + original = sep_settings.SESSION_REFRESH.MAX_AGE api_admin_client.patch( "/api/sep/admin/settings/SEPSettings", - json={"SESSION__MAX_AGE": override_seconds}, + json={"SESSION_REFRESH__MAX_AGE": override_seconds}, ) - assert sep_settings.SESSION.MAX_AGE.total_seconds() == override_seconds + assert sep_settings.SESSION_REFRESH.MAX_AGE.total_seconds() == override_seconds response = api_admin_client.delete( - "/api/sep/admin/settings/SEPSettings/SESSION__MAX_AGE" + "/api/sep/admin/settings/SEPSettings/SESSION_REFRESH__MAX_AGE" ) assert response.status_code == status.HTTP_204_NO_CONTENT - assert original == sep_settings.SESSION.MAX_AGE + assert original == sep_settings.SESSION_REFRESH.MAX_AGE async def test_delete_nested_override_idempotent_when_absent( self, api_admin_client: TestClient ) -> None: """Return 204 when deleting a never-set nested override.""" response = api_admin_client.delete( - "/api/sep/admin/settings/SEPSettings/SESSION__MAX_AGE" + "/api/sep/admin/settings/SEPSettings/SESSION_REFRESH__MAX_AGE" ) assert response.status_code == status.HTTP_204_NO_CONTENT @@ -1479,14 +1473,18 @@ async def test_list_marks_overridden_leaf_independently_of_siblings( """Assert each leaf carries its own ``has_override``; a sibling stays ``False``.""" api_admin_client.patch( "/api/sep/admin/settings/SEPSettings", - json={"SESSION__SAMESITE": "strict"}, + json={"SESSION_REFRESH__SAMESITE": "strict"}, ) list_payload = api_admin_client.get("/api/sep/admin/settings/").json() overridden = _find_setting( - list_payload, SettingClassEnum.SEP_SETTINGS.value, "SESSION__SAMESITE" + list_payload, + SettingClassEnum.SEP_SETTINGS.value, + "SESSION_REFRESH__SAMESITE", ) sibling = _find_setting( - list_payload, SettingClassEnum.SEP_SETTINGS.value, "SESSION__MAX_AGE" + list_payload, + SettingClassEnum.SEP_SETTINGS.value, + "SESSION_REFRESH__MAX_AGE", ) assert overridden["has_override"] is True assert sibling["has_override"] is False @@ -1567,19 +1565,6 @@ async def test_patch_snippets_setting(self, api_admin_client: TestClient) -> Non finally: snippets_settings._set_snapshot({}) - async def test_patch_messages_setting(self, api_admin_client: TestClient) -> None: - """Assert a Messages HOT field is patchable via the SEP router.""" - target_level = 30 - response = api_admin_client.patch( - "/api/sep/admin/settings/MessagesSettings", - json={"LEVEL": target_level}, - ) - try: - assert response.status_code == status.HTTP_200_OK - assert target_level == messages_settings.LEVEL - finally: - messages_settings._set_snapshot({}) - @pytest.mark.asyncio class TestSepSettingsAlertSettings: diff --git a/tests/app/sep/api/routes/test_settings_export.py b/tests/app/sep/api/routes/test_settings_export.py index 292cc7951c..48718c2e83 100644 --- a/tests/app/sep/api/routes/test_settings_export.py +++ b/tests/app/sep/api/routes/test_settings_export.py @@ -38,12 +38,10 @@ from app.sep.bundle_upload.plan import DeliveryPlan from app.sep.config import DeliveryPlanInputs, sep_settings, SEPSettings from app.sep.deps import ( - get_api_authenticated_user, get_current_user, get_session, get_tasks_api, require_bearer_for_unsafe_methods, - validate_csrf, ) from app.sep.main import sep_app @@ -124,9 +122,7 @@ def api_admin_client_fixture( mock_tasks_api: AsyncMock, ) -> Iterator[TestClient]: """Yield an admin-authenticated SEP TestClient with the in-memory SEP session.""" - sep_app.dependency_overrides[validate_csrf] = lambda: True sep_app.dependency_overrides[get_current_user] = lambda: admin_user - sep_app.dependency_overrides[get_api_authenticated_user] = lambda: admin_user sep_app.dependency_overrides[get_session] = lambda: override_session sep_app.dependency_overrides[require_bearer_for_unsafe_methods] = lambda: None sep_app.dependency_overrides[get_tasks_api] = lambda: mock_tasks_api @@ -141,9 +137,7 @@ def api_non_admin_client_fixture( mock_tasks_api: AsyncMock, ) -> Iterator[TestClient]: """Yield a non-admin SEP TestClient with the in-memory SEP session.""" - sep_app.dependency_overrides[validate_csrf] = lambda: True sep_app.dependency_overrides[get_current_user] = lambda: regular_user - sep_app.dependency_overrides[get_api_authenticated_user] = lambda: regular_user sep_app.dependency_overrides[get_session] = lambda: override_session sep_app.dependency_overrides[require_bearer_for_unsafe_methods] = lambda: None sep_app.dependency_overrides[get_tasks_api] = lambda: mock_tasks_api @@ -235,7 +229,6 @@ async def test_export_keys_match_list_for_sep_classes( for setting_class in ( SettingClassEnum.SEP_SETTINGS.value, SettingClassEnum.SNIPPETS_SETTINGS.value, - SettingClassEnum.MESSAGES_SETTINGS.value, SettingClassEnum.ALERTS_SETTINGS.value, ): assert set(export[setting_class]) == list_keys[setting_class] @@ -573,7 +566,6 @@ async def test_tasks_group_missing_settings_returns_502( SEP_CLASS = SettingClassEnum.SEP_SETTINGS.value SNIPPETS_CLASS = SettingClassEnum.SNIPPETS_SETTINGS.value -MESSAGES_CLASS = SettingClassEnum.MESSAGES_SETTINGS.value ALERTS_CLASS = SettingClassEnum.ALERTS_SETTINGS.value SETTINGS_CLASS = SettingClassEnum.SETTINGS.value ALERT_CLASS = SettingClassEnum.ALERT_SETTINGS.value @@ -581,7 +573,6 @@ async def test_tasks_group_missing_settings_returns_502( FULL_EXPORT_CLASSES = { SEP_CLASS, SNIPPETS_CLASS, - MESSAGES_CLASS, ALERTS_CLASS, SETTINGS_CLASS, ALERT_CLASS, @@ -657,17 +648,17 @@ async def test_whole_class_selector_keeps_all_keys( async def test_mixed_class_and_key_selectors( self, api_admin_client: TestClient ) -> None: - """Yield exactly two blocks for ``SEPSettings.`` plus whole ``MessagesSettings``.""" + """Yield exactly two blocks for ``SEPSettings.`` plus whole ``AlertsSettings``.""" key = _one_sep_key(api_admin_client) list_keys = _list_keys_by_class(api_admin_client) response = api_admin_client.get( - EXPORT_URL, params={"keys": [f"{SEP_CLASS}.{key}", MESSAGES_CLASS]} + EXPORT_URL, params={"keys": [f"{SEP_CLASS}.{key}", ALERTS_CLASS]} ) assert response.status_code == status.HTTP_200_OK payload = yaml.safe_load(response.text) - assert set(payload) == {SEP_CLASS, MESSAGES_CLASS} + assert set(payload) == {SEP_CLASS, ALERTS_CLASS} assert set(payload[SEP_CLASS]) == {key} - assert set(payload[MESSAGES_CLASS]) == list_keys[MESSAGES_CLASS] + assert set(payload[ALERTS_CLASS]) == list_keys[ALERTS_CLASS] async def test_block_order_is_canonical_not_selector_order( self, api_admin_client: TestClient diff --git a/tests/app/sep/api/routes/test_settings_proxy.py b/tests/app/sep/api/routes/test_settings_proxy.py index 138743a3cd..44a0c63772 100644 --- a/tests/app/sep/api/routes/test_settings_proxy.py +++ b/tests/app/sep/api/routes/test_settings_proxy.py @@ -43,12 +43,10 @@ from app.core.settings_override.models import SettingClassEnum from app.core.utils import json_serializer from app.sep.deps import ( - get_api_authenticated_user, get_current_user, get_session, get_tasks_api, require_bearer_for_unsafe_methods, - validate_csrf, ) from app.sep.main import sep_app @@ -118,9 +116,7 @@ def admin_client_fixture( admin_user: CasdoorUser, override_session: AsyncSession, mock_tasks: AsyncMock ) -> Iterator[TestClient]: """Yield an admin client (Bearer gate satisfied) with the Tasks API mocked.""" - sep_app.dependency_overrides[validate_csrf] = lambda: True sep_app.dependency_overrides[get_current_user] = lambda: admin_user - sep_app.dependency_overrides[get_api_authenticated_user] = lambda: admin_user sep_app.dependency_overrides[get_session] = lambda: override_session sep_app.dependency_overrides[require_bearer_for_unsafe_methods] = lambda: None yield TestClient(sep_app, raise_server_exceptions=False) @@ -132,9 +128,7 @@ def non_admin_client_fixture( regular_user: CasdoorUser, override_session: AsyncSession, mock_tasks: AsyncMock ) -> Iterator[TestClient]: """Yield a non-admin client (admin gate should reject) with the Tasks API mocked.""" - sep_app.dependency_overrides[validate_csrf] = lambda: True sep_app.dependency_overrides[get_current_user] = lambda: regular_user - sep_app.dependency_overrides[get_api_authenticated_user] = lambda: regular_user sep_app.dependency_overrides[get_session] = lambda: override_session sep_app.dependency_overrides[require_bearer_for_unsafe_methods] = lambda: None yield TestClient(sep_app, raise_server_exceptions=False) @@ -150,9 +144,7 @@ def cookie_admin_client_fixture( ``require_bearer_for_unsafe_methods`` is deliberately left un-overridden so unsafe methods (PATCH / DELETE) without a Bearer header reject with 401. """ - sep_app.dependency_overrides[validate_csrf] = lambda: True sep_app.dependency_overrides[get_current_user] = lambda: admin_user - sep_app.dependency_overrides[get_api_authenticated_user] = lambda: admin_user sep_app.dependency_overrides[get_session] = lambda: override_session yield TestClient(sep_app, raise_server_exceptions=False) sep_app.dependency_overrides = {} @@ -169,7 +161,7 @@ def test_appends_tasks_group_fetched_server_side( response = admin_client.get("/api/sep/admin/settings/") assert response.status_code == status.HTTP_200_OK classes = [g["setting_class"] for g in response.json()["groups"]] - assert {"SEPSettings", "SnippetsSettings", "MessagesSettings"}.issubset( + assert {"SEPSettings", "SnippetsSettings", "AlertSettings"}.issubset( set(classes) ) assert classes[-2] == SettingClassEnum.TASKS_SETTINGS.value @@ -179,7 +171,7 @@ def test_appends_tasks_group_fetched_server_side( def test_list_emits_is_advanced_for_sep_settings( self, admin_client: TestClient, mock_tasks: AsyncMock ) -> None: - """The SEP group flags advanced settings (incl. every SESSION leaf) and only those.""" + """Flag advanced settings (incl. every SESSION_REFRESH leaf) and only those.""" mock_tasks.get.return_value = _tasks_list() response = admin_client.get("/api/sep/admin/settings/") assert response.status_code == status.HTTP_200_OK @@ -193,11 +185,9 @@ def test_list_emits_is_advanced_for_sep_settings( assert advanced["INVENTORY_ENDPOINT"] is True assert advanced["TASKS_ENDPOINT"] is True assert advanced["FOOTER_TEMPLATE"] is True - # Every expanded SESSION / SESSION_REFRESH leaf inherits the flag. - session_leaves = [ - k for k in advanced if k.startswith(("SESSION__", "SESSION_REFRESH__")) - ] - assert session_leaves # the parents expand into leaves, not a single entry + # Every expanded SESSION_REFRESH leaf inherits the flag. + session_leaves = [k for k in advanced if k.startswith("SESSION_REFRESH__")] + assert session_leaves # the parent expands into leaves, not a single entry assert all(advanced[k] is True for k in session_leaves) # A basic setting stays False. assert advanced["SYNC_REFRESH_TIME"] is False diff --git a/tests/app/sep/api/test_router.py b/tests/app/sep/api/test_router.py index d94aaf6906..112290ef5d 100644 --- a/tests/app/sep/api/test_router.py +++ b/tests/app/sep/api/test_router.py @@ -30,11 +30,9 @@ from app.sep.config import App, sep_settings from app.sep.deps import ( BEARER_REQUIRED_DETAIL, - get_api_authenticated_user, get_current_user, IsApiAuthenticated, require_bearer_for_unsafe_methods, - validate_csrf, ) from app.sep.main import sep_app @@ -158,16 +156,13 @@ def test_unauthenticated_unknown_plugin_returns_json_404( @pytest.fixture def cookie_only_client(regular_user): - """Return a TestClient that has cookie auth but no Bearer override. + """Return an authenticated TestClient with the Bearer gate left intact. - Overrides ``get_current_user``, ``get_api_authenticated_user`` and - ``validate_csrf`` so the request passes the existing auth/CSRF stack, - but deliberately leaves ``require_bearer_for_unsafe_methods`` unmocked - so the framework Bearer gate fires for mutating methods. + Overrides ``get_current_user`` so the request passes authentication, but + deliberately leaves ``require_bearer_for_unsafe_methods`` unmocked so the + Bearer gate fires for mutating methods. """ sep_app.dependency_overrides[get_current_user] = lambda: regular_user - sep_app.dependency_overrides[get_api_authenticated_user] = lambda: regular_user - sep_app.dependency_overrides[validate_csrf] = lambda: True yield TestClient(sep_app, raise_server_exceptions=False) sep_app.dependency_overrides = {} @@ -249,17 +244,21 @@ def test_options_method_passes_bearer_gate( response = cookie_only_client.options("/api/apps/checksums/schema") assert response.status_code != status.HTTP_401_UNAUTHORIZED - def test_bearer_gate_is_on_apps_router_only(self) -> None: - """Check the Bearer gate is wired into apps_router, not the broader api_router. + def test_bearer_gate_is_hoisted_to_the_api_router(self) -> None: + """Check the Bearer gate covers the whole ``/api`` tree, not just ``/apps``. - Regression guard against accidentally Bearer-gating /api/sep/* - (dashboard, hosts, task-stats) which serve cookie-authenticated - React reads. + With cookie authentication gone, every ``/api`` caller presents a Bearer + token, so the gate is hoisted to ``api_router`` and every mutating route + inherits it uniformly rather than per-router. The gate is method-scoped, + so reads are unaffected; ``test_api_sep_get_routes_are_not_bearer_gated`` + pins that half. """ plugin_deps = [dep.dependency for dep in apps_router.dependencies] api_deps = [dep.dependency for dep in api_router.dependencies] + assert require_bearer_for_unsafe_methods in api_deps + # Kept on apps_router too: FastAPI caches identical ``Depends`` objects + # per request, so the duplicate registration executes once. assert require_bearer_for_unsafe_methods in plugin_deps - assert require_bearer_for_unsafe_methods not in api_deps def test_api_sep_get_routes_are_not_bearer_gated( self, @@ -268,11 +267,11 @@ def test_api_sep_get_routes_are_not_bearer_gated( mock_inventory_api_dep, mocker, ) -> None: - """Cookie-only GET on /api/sep/* is not blocked by the Bearer gate. + """Serve a GET on /api/sep/* without tripping the Bearer gate. - Regression guard: dashboard/hosts/task-stats serve cookie-auth - React reads; the response must succeed (200) under cookie-only - credentials. Upstream Tasks/Inventory and the SEP snippets count + Regression guard for the hoist: the gate is method-scoped, so reads + must still succeed (200) even without a Bearer header on the request + itself. Upstream Tasks/Inventory and the SEP snippets count are stubbed so the dashboard returns a deterministic payload independent of any persisted snippet rows in the local SEP DB. """ @@ -683,8 +682,6 @@ def test_unmounted_plugin_returns_404(self, test_client: TestClient) -> None: "/api/apps/mysql_backups/restore/some-task", "/api/apps/mysql_backups/restore", ), - ("GET", "/mysql_backups/restores/", "/mysql_backups/restores"), - ("GET", "/mysql_backups/restores/some-task", "/mysql_backups/restores"), ( "GET", "/api/apps/backup_mongo/restore/", @@ -705,8 +702,6 @@ def test_unmounted_plugin_returns_404(self, test_client: TestClient) -> None: "/api/apps/backup_mongo/restore/some-task", "/api/apps/backup_mongo/restore", ), - ("GET", "/backup_mongo/restores/", "/backup_mongo/restores"), - ("GET", "/backup_mongo/restores/some-task", "/backup_mongo/restores"), ], ) def test_scoped_restore_routes_not_shadowed_by_parent( diff --git a/tests/app/sep/apps/alert_troubleshooting/test_routes.py b/tests/app/sep/apps/alert_troubleshooting/test_routes.py deleted file mode 100644 index 6297b14dff..0000000000 --- a/tests/app/sep/apps/alert_troubleshooting/test_routes.py +++ /dev/null @@ -1,353 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Test the alert troubleshooting plugin routes.""" - -from types import SimpleNamespace -from unittest.mock import AsyncMock - -from fastapi import HTTPException, status -from starlette.testclient import TestClient - -from app.core.requests import RemoteAPI -from app.sep.apps.alert_troubleshooting.deps import ( - AlertInfo, - get_ajax_executable_snippet, - get_ajax_execution_request_meta, - get_troubleshooting_detail_context, - get_troubleshooting_index_context, -) -from app.sep.deps import get_tasks_api -from app.sep.main import sep_app -from app.sep.models import AlertServiceType -from app.sep.snippets.models.snippet import SnippetExecutionMeta - -MOCK_TASK_ID = 42 - - -def _override_context(grouped_alerts): - """Build a context override with the given grouped alerts.""" - - async def _mock_context(): - return { - "grouped_alerts": grouped_alerts, - "alert_service_types": list(AlertServiceType), - "base_uri": "/alert-troubleshooting", - } - - return _mock_context - - -class TestTroubleshootingIndex: - """Test the alert troubleshooting index route.""" - - def test_index_returns_200(self, test_client: TestClient): - """Assert GET /alert-troubleshooting/ returns 200.""" - grouped = { - AlertServiceType.MYSQL: [ - AlertInfo(name="MySQLSlowQueries", label="MySQL Slow Queries"), - ], - } - sep_app.dependency_overrides[get_troubleshooting_index_context] = ( - _override_context(grouped) - ) - response = test_client.get("/alert-troubleshooting/") - assert response.status_code == status.HTTP_200_OK - - def test_index_renders_alert_names(self, test_client: TestClient): - """Assert the response includes alert display names.""" - grouped = { - AlertServiceType.POSTGRESQL: [ - AlertInfo( - name="PostgreSQLLockConflicts", - label="PostgreSQL Lock Conflicts", - ), - ], - } - sep_app.dependency_overrides[get_troubleshooting_index_context] = ( - _override_context(grouped) - ) - response = test_client.get("/alert-troubleshooting/") - assert "PostgreSQL Lock Conflicts" in response.text - - def test_index_renders_service_type_header(self, test_client: TestClient): - """Assert the response includes service type section headers.""" - grouped = { - AlertServiceType.MYSQL: [ - AlertInfo(name="MySQLSlowQueries", label="MySQL Slow Queries"), - ], - } - sep_app.dependency_overrides[get_troubleshooting_index_context] = ( - _override_context(grouped) - ) - response = test_client.get("/alert-troubleshooting/") - assert "MySQL" in response.text - - def test_index_empty_state(self, test_client: TestClient): - """Assert the empty state message renders when no alerts exist.""" - sep_app.dependency_overrides[get_troubleshooting_index_context] = ( - _override_context({}) - ) - response = test_client.get("/alert-troubleshooting/") - assert response.status_code == status.HTTP_200_OK - assert "No alert troubleshooting guides" in response.text - - def test_index_alert_links_to_detail(self, test_client: TestClient): - """Assert alert links point to the detail page URL.""" - grouped = { - AlertServiceType.GENERIC: [ - AlertInfo(name="HighCPUUsage", label="High CPU Usage"), - ], - } - sep_app.dependency_overrides[get_troubleshooting_index_context] = ( - _override_context(grouped) - ) - response = test_client.get("/alert-troubleshooting/") - assert "/alert-troubleshooting/generic/HighCPUUsage" in response.text - - -def _override_detail_context(alert_info, snippets, executor_hosts): - """Build a detail context override.""" - - async def _mock_context(): - return { - "alert_info": alert_info, - "snippets": snippets, - "executor_hosts": executor_hosts, - "base_uri": "/alert-troubleshooting", - } - - return _mock_context - - -class TestTroubleshootingDetail: - """Test the alert troubleshooting detail route.""" - - def test_detail_returns_200(self, test_client: TestClient): - """Assert GET /{alert_name} returns 200 with valid context.""" - alert_info = AlertInfo(name="HighCPU", label="High CPU") - sep_app.dependency_overrides[get_troubleshooting_detail_context] = ( - _override_detail_context(alert_info, [], []) - ) - response = test_client.get("/alert-troubleshooting/generic/HighCPU") - assert response.status_code == status.HTTP_200_OK - - def test_detail_renders_alert_label(self, test_client: TestClient): - """Assert the detail page renders the alert display label.""" - alert_info = AlertInfo(name="MySQLSlowQueries", label="MySQL Slow Queries") - sep_app.dependency_overrides[get_troubleshooting_detail_context] = ( - _override_detail_context(alert_info, [], []) - ) - response = test_client.get("/alert-troubleshooting/mysql/MySQLSlowQueries") - assert "MySQL Slow Queries" in response.text - - def test_detail_empty_snippets_shows_empty_state(self, test_client: TestClient): - """Assert the detail page renders empty state when no snippets.""" - alert_info = AlertInfo(name="EmptyAlert", label="Empty Alert") - sep_app.dependency_overrides[get_troubleshooting_detail_context] = ( - _override_detail_context(alert_info, [], []) - ) - response = test_client.get("/alert-troubleshooting/generic/EmptyAlert") - assert response.status_code == status.HTTP_200_OK - assert "No snippets are associated" in response.text - - def test_detail_renders_executor_hosts(self, test_client: TestClient): - """Assert the detail page renders executor host options.""" - alert_info = AlertInfo(name="HighCPU", label="High CPU") - hosts = [{"value": "node-1", "label": "Node 1"}] - sep_app.dependency_overrides[get_troubleshooting_detail_context] = ( - _override_detail_context(alert_info, [], hosts) - ) - response = test_client.get("/alert-troubleshooting/generic/HighCPU") - assert "Node 1" in response.text - - -class TestTroubleshootingExecute: - """Test the AJAX snippet execution endpoint.""" - - @staticmethod - def _mock_snippet(): - """Create a mock executable snippet.""" - return SimpleNamespace( - filename="test.sh", - execution_task_name="run-command", - can_execute=True, - is_approved=True, - ) - - @staticmethod - def _mock_meta(): - """Create a mock execution metadata.""" - return SnippetExecutionMeta( - target="node-1", - interpreter="bash", - snippet_source="https://example.com/test.sh", - snippet_filename="test.sh", - md5_checksum="d" * 32, - ) - - def test_execute_success(self, test_client: TestClient): - """Assert POST /execute?snippet_filename=... returns JSON with task ID.""" - mock_api = AsyncMock(spec=RemoteAPI) - mock_api.post.return_value = {"id": MOCK_TASK_ID, "status": "running"} - sep_app.dependency_overrides[get_tasks_api] = lambda: mock_api - sep_app.dependency_overrides[get_ajax_executable_snippet] = self._mock_snippet - sep_app.dependency_overrides[get_ajax_execution_request_meta] = self._mock_meta - response = test_client.post( - "/alert-troubleshooting/execute", - params={"snippet_filename": "test.sh"}, - data={"-hostname-": "node-1", "csrf-token": "fake"}, - ) - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["task_id"] == MOCK_TASK_ID - assert data["status"] == "submitted" - - def test_execute_tasks_api_error(self, test_client: TestClient): - """Assert JSON error response when Tasks API fails.""" - mock_api = AsyncMock(spec=RemoteAPI) - mock_api.post.side_effect = HTTPException(status_code=502, detail="Bad Gateway") - sep_app.dependency_overrides[get_tasks_api] = lambda: mock_api - sep_app.dependency_overrides[get_ajax_executable_snippet] = self._mock_snippet - sep_app.dependency_overrides[get_ajax_execution_request_meta] = self._mock_meta - response = test_client.post( - "/alert-troubleshooting/execute", - params={"snippet_filename": "test.sh"}, - data={"-hostname-": "node-1", "csrf-token": "fake"}, - ) - assert response.status_code == status.HTTP_502_BAD_GATEWAY - assert "error" in response.json() - - def test_execute_resolves_nested_filename_via_query(self, test_client: TestClient): - """Assert POST /execute with a nested snippet_filename query returns JSON with task ID.""" - mock_api = AsyncMock(spec=RemoteAPI) - mock_api.post.return_value = {"id": MOCK_TASK_ID, "status": "running"} - sep_app.dependency_overrides[get_tasks_api] = lambda: mock_api - sep_app.dependency_overrides[get_ajax_executable_snippet] = self._mock_snippet - sep_app.dependency_overrides[get_ajax_execution_request_meta] = self._mock_meta - response = test_client.post( - "/alert-troubleshooting/execute", - params={"snippet_filename": "diag/slow-query.sh"}, - data={"-hostname-": "node-1", "csrf-token": "fake"}, - ) - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["task_id"] == MOCK_TASK_ID - - def test_execute_legacy_path_shape_does_not_match(self, test_client: TestClient): - """Assert legacy POST /execute/ returns 404 or 405 and does not call Tasks API.""" - mock_api = AsyncMock(spec=RemoteAPI) - sep_app.dependency_overrides[get_tasks_api] = lambda: mock_api - sep_app.dependency_overrides[get_ajax_executable_snippet] = self._mock_snippet - sep_app.dependency_overrides[get_ajax_execution_request_meta] = self._mock_meta - response = test_client.post( - "/alert-troubleshooting/execute/test.sh", - data={"-hostname-": "node-1", "csrf-token": "fake"}, - ) - assert response.status_code in { - status.HTTP_404_NOT_FOUND, - status.HTTP_405_METHOD_NOT_ALLOWED, - } - mock_api.post.assert_not_called() - - -class TestTroubleshootingOutput: - """Test the AJAX output polling endpoint.""" - - def test_output_running(self, test_client: TestClient): - """Assert running task returns status without output.""" - mock_api = AsyncMock(spec=RemoteAPI) - mock_api.get.return_value = {"id": 1, "status": "running"} - sep_app.dependency_overrides[get_tasks_api] = lambda: mock_api - response = test_client.get("/alert-troubleshooting/output/1") - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["status"] == "running" - - def test_output_success(self, test_client: TestClient): - """Assert successful task returns status and stdout log output.""" - mock_api = AsyncMock(spec=RemoteAPI) - mock_api.get.return_value = {"id": 1, "status": "success"} - - async def _mock_stream(path, **kwargs): - yield b'{"step":"run-script","type":"stdout","msg":"query result OK","offset":15}' - - mock_api.stream = _mock_stream - sep_app.dependency_overrides[get_tasks_api] = lambda: mock_api - response = test_client.get("/alert-troubleshooting/output/1") - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["status"] == "success" - assert "query result OK" in data["output"] - - def test_output_failed(self, test_client: TestClient): - """Assert failed task returns stderr in the error field.""" - mock_api = AsyncMock(spec=RemoteAPI) - mock_api.get.return_value = {"id": 1, "status": "failed"} - - async def _mock_stream(path, **kwargs): - yield b'{"step":"run-script","type":"stderr","msg":"Error: connection refused","offset":25}' - - mock_api.stream = _mock_stream - sep_app.dependency_overrides[get_tasks_api] = lambda: mock_api - response = test_client.get("/alert-troubleshooting/output/1") - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["status"] == "failed" - assert data["output"] == "" - assert data["error"] == "Error: connection refused" - - def test_output_failed_no_stderr(self, test_client: TestClient): - """Assert failed task without stderr omits the error field.""" - mock_api = AsyncMock(spec=RemoteAPI) - mock_api.get.return_value = {"id": 1, "status": "failed"} - - async def _mock_stream(path, **kwargs): - yield b'{"step":"run-script","type":"stdout","msg":"","offset":0}' - - mock_api.stream = _mock_stream - sep_app.dependency_overrides[get_tasks_api] = lambda: mock_api - response = test_client.get("/alert-troubleshooting/output/1") - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["status"] == "failed" - assert data["output"] == "" - assert "error" not in data - - def test_output_tasks_api_error(self, test_client: TestClient): - """Assert JSON error response when Tasks API returns an error.""" - mock_api = AsyncMock(spec=RemoteAPI) - mock_api.get.side_effect = HTTPException(status_code=502, detail="Bad Gateway") - sep_app.dependency_overrides[get_tasks_api] = lambda: mock_api - response = test_client.get("/alert-troubleshooting/output/1") - assert response.status_code == status.HTTP_502_BAD_GATEWAY - assert "error" in response.json() - - -class TestDeprecationHeader: - """Assert that Jinja2 routes emit RFC 8594 ``Deprecation`` header.""" - - def test_index_has_deprecation_header(self, test_client: TestClient): - """GET /alert-troubleshooting/ response carries ``Deprecation: true``.""" - grouped = { - AlertServiceType.MYSQL: [ - AlertInfo(name="MySQLSlowQueries", label="MySQL Slow Queries"), - ], - } - sep_app.dependency_overrides[get_troubleshooting_index_context] = ( - _override_context(grouped) - ) - response = test_client.get("/alert-troubleshooting/") - assert response.headers.get("Deprecation") == "true" diff --git a/tests/app/sep/apps/alerts/test_deps.py b/tests/app/sep/apps/alerts/test_deps.py index 31fff0d3bf..c4e26a0771 100644 --- a/tests/app/sep/apps/alerts/test_deps.py +++ b/tests/app/sep/apps/alerts/test_deps.py @@ -25,7 +25,6 @@ from app.sep.apps.alerts import deps as alerts_deps from app.sep.apps.alerts.deps import ( ensure_pagerduty_notification_route, - get_alerts_index_context, get_or_create_alert_folder, get_pagerduty_status, get_pmm_present_names, @@ -33,7 +32,6 @@ ) from app.sep.apps.alerts.models import ( AlertTemplate, - ServiceType, ) from app.sep.clients.pmm import AlertTemplate as PMMAlertTemplate from app.sep.clients.pmm import ContactPoint, Folder, NotificationPolicy, PMMRemoteAPI @@ -224,55 +222,6 @@ async def test_preserves_existing_routes(self): assert updated_policy.routes[0] == existing_route -class TestGetAlertsIndexContext: - """Test the ``get_alerts_index_context`` dependency.""" - - @pytest.mark.asyncio - async def test_assembles_context_with_all_fields(self): - """Assert the context contains all expected keys.""" - base_context = {"user": "test-user", "plugins": []} - templates_by_service = { - ServiceType.GENERIC: (AlertTemplateFactory.build(),), - ServiceType.MYSQL: ( - AlertTemplateFactory.build(service_type=ServiceType.MYSQL), - ), - ServiceType.MONGODB: (), - ServiceType.POSTGRESQL: (), - } - pmm_names = {"High CPU"} - pd_status = {"configured": False} - - result = await get_alerts_index_context( - base_context, templates_by_service, pmm_names, [], pd_status - ) - - assert "all_templates" in result - assert "service_types" in result - assert "pmm_present_names" in result - assert "recent_backups" in result - assert "pagerduty_status" in result - expected_template_count = sum(len(ts) for ts in templates_by_service.values()) - assert len(result["all_templates"]) == expected_template_count - assert result["service_types"] == list(ServiceType) - assert result["pmm_present_names"] is pmm_names - assert result["pagerduty_status"] is pd_status - assert result["user"] == "test-user" - - @pytest.mark.asyncio - async def test_pmm_present_names_can_be_none(self): - """Assert the context works when PMM is unavailable.""" - base_context = {"user": "test-user"} - templates_by_service = {svc: () for svc in ServiceType} - - result = await get_alerts_index_context( - base_context, templates_by_service, None, [], None - ) - - assert result["pmm_present_names"] is None - assert result["pagerduty_status"] is None - assert result["all_templates"] == [] - - class TestGetOrCreateAlertFolder: """Test the ``get_or_create_alert_folder`` dependency.""" diff --git a/tests/app/sep/apps/alerts/test_restore.py b/tests/app/sep/apps/alerts/test_restore.py index f3db95bf41..f54be228c3 100644 --- a/tests/app/sep/apps/alerts/test_restore.py +++ b/tests/app/sep/apps/alerts/test_restore.py @@ -15,11 +15,9 @@ """Define tests for restore from backup functionality.""" -from datetime import datetime, UTC from unittest.mock import AsyncMock import pytest -from fastapi import status from fastapi.exceptions import HTTPException from app.core.exceptions import HTTPConflictException, HTTPNotFoundException @@ -488,167 +486,6 @@ async def test_restore_creates_folder_when_missing(self): assert call_kwargs["folder_uid"] == "new-f1" -class TestRestoreRoute: - """Test the POST /alerts/restore route.""" - - @pytest.fixture - def _mock_pmm_api_dep(self): - """Override the PMM API dependency.""" - from app.sep.apps.alerts.deps import get_pmm_api - from app.sep.main import sep_app - - mock_api = AsyncMock(spec=PMMRemoteAPI) - mock_api.list_rules.return_value = [] - mock_api.list_folders.return_value = [ - Folder(uid="f1", title="SEP Alerts", id=1), - ] - mock_api.template_exists.return_value = False - mock_api.create_template.return_value = PMMAlertTemplate( - name="t1", summary="s", template="y" - ) - mock_api.create_rule.return_value = AlertRule(uid="r1", title="Rule 1") - mock_api.list_contact_points.return_value = [] - mock_api.update_notification_policy.return_value = NotificationPolicy( - receiver="default" - ) - sep_app.dependency_overrides[get_pmm_api] = lambda: mock_api - yield mock_api - sep_app.dependency_overrides.pop(get_pmm_api, None) - - @pytest.fixture - def _mock_pmm_api_none_dep(self): - """Override the PMM API dependency to return None.""" - from app.sep.apps.alerts.deps import get_pmm_api - from app.sep.main import sep_app - - sep_app.dependency_overrides[get_pmm_api] = lambda: None - yield - sep_app.dependency_overrides.pop(get_pmm_api, None) - - @pytest.fixture - def _mock_backup_get(self, mocker): - """Mock AlertBackupManager.get_or_404 to return a sample backup.""" - backup = AlertBackup( - id=1, - data=_sample_backup_data(), - metadata_={"template_count": 2, "rule_count": 1}, - ) - mocker.patch( - "app.sep.apps.alerts.routes.AlertBackupManager.get_or_404", - new=AsyncMock(return_value=backup), - ) - - @pytest.fixture - def _mock_backup_not_found(self, mocker): - """Mock AlertBackupManager.get_or_404 to raise 404.""" - from app.core.exceptions import HTTPNotFoundException - - mocker.patch( - "app.sep.apps.alerts.routes.AlertBackupManager.get_or_404", - new=AsyncMock(side_effect=HTTPNotFoundException()), - ) - - @pytest.mark.usefixtures("_mock_pmm_api_dep", "_mock_backup_get") - def test_restore_success(self, test_client): - """Assert POST /alerts/restore returns success JSON.""" - response = test_client.post( - "/alerts/restore", - data={"backup_id": "1"}, - ) - assert response.status_code == status.HTTP_200_OK - body = response.json() - assert body["status"] == "success" - assert "details" in body - - @pytest.mark.usefixtures("_mock_pmm_api_none_dep") - def test_restore_pmm_not_configured(self, test_client): - """Assert 503 when PMM is not configured.""" - response = test_client.post( - "/alerts/restore", - data={"backup_id": "1"}, - ) - assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE - assert "PMM is not configured" in response.json()["message"] - - @pytest.mark.usefixtures("_mock_pmm_api_dep", "_mock_backup_not_found") - def test_restore_backup_not_found(self, test_client): - """Assert 404 JSON error when backup_id does not exist.""" - response = test_client.post( - "/alerts/restore", - data={"backup_id": "999"}, - ) - assert response.status_code == status.HTTP_404_NOT_FOUND - body = response.json() - assert body["status"] == "error" - - @pytest.mark.usefixtures("_mock_pmm_api_dep", "_mock_backup_get") - def test_restore_pmm_api_error(self, test_client, mocker): - """Assert 502 when PMM API fails during restore.""" - mocker.patch( - "app.sep.apps.alerts.routes.restore_from_backup", - new=AsyncMock( - side_effect=HTTPException(status_code=502, detail="Bad Gateway") - ), - ) - response = test_client.post( - "/alerts/restore", - data={"backup_id": "1"}, - ) - assert response.status_code == status.HTTP_502_BAD_GATEWAY - body = response.json() - assert body["status"] == "error" - - -class TestBackupDetailRoute: - """Test the GET /alerts/backups/{backup_id} route.""" - - @pytest.fixture - def _mock_backup_get(self, mocker): - """Mock AlertBackupManager.get to return a sample backup.""" - backup = AlertBackup( - id=1, - data=_sample_backup_data(), - metadata_={"template_count": 2, "rule_count": 1}, - created_at=datetime(2026, 3, 17, 22, 0, tzinfo=UTC), - ) - mocker.patch( - "app.sep.apps.alerts.routes.AlertBackupManager.get", - new=AsyncMock(return_value=backup), - ) - - @pytest.fixture - def _mock_backup_not_found(self, mocker): - """Mock AlertBackupManager.get to return None.""" - mocker.patch( - "app.sep.apps.alerts.routes.AlertBackupManager.get", - new=AsyncMock(return_value=None), - ) - - @pytest.mark.usefixtures("_mock_backup_get") - def test_backup_detail_success(self, test_client): - """Assert GET /alerts/backups/1 returns 200 with backup summary.""" - response = test_client.get("/alerts/backups/1") - assert response.status_code == status.HTTP_200_OK - body = response.json() - assert body["id"] == 1 - assert body["created_at"] == "2026-03-17 22:00 UTC" - assert len(body["templates"]) == _SAMPLE_TEMPLATE_COUNT - assert body["templates"][0]["name"] == "t1" - assert len(body["rules"]) == 1 - assert body["rules"][0]["title"] == "Rule 1" - assert len(body["contact_points"]) == _SAMPLE_CONTACT_POINT_COUNT - assert len(body["folders"]) == 1 - assert body["notification_policy_receiver"] == "Email" - - @pytest.mark.usefixtures("_mock_backup_not_found") - def test_backup_detail_not_found(self, test_client): - """Assert GET /alerts/backups/999 returns 404 JSON.""" - response = test_client.get("/alerts/backups/999") - assert response.status_code == status.HTTP_404_NOT_FOUND - body = response.json() - assert body["status"] == "error" - - class TestGetRecentBackups: """Test the ``get_recent_backups`` dependency.""" diff --git a/tests/app/sep/apps/alerts/test_routes.py b/tests/app/sep/apps/alerts/test_routes.py deleted file mode 100644 index cce03d3948..0000000000 --- a/tests/app/sep/apps/alerts/test_routes.py +++ /dev/null @@ -1,396 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define tests for the app.sep.apps.alerts.routes module.""" - -from collections.abc import Mapping -from unittest.mock import AsyncMock - -import pytest -from fastapi import status -from fastapi.exceptions import HTTPException - -from app.sep.apps.alerts.deps import ( - get_alert_templates, - get_alerts_index_context, - get_or_create_alert_folder, - get_pmm_api, - get_pmm_present_names, -) -from app.sep.apps.alerts.models import AlertSeverity, AlertTemplate, ServiceType -from app.sep.clients.pmm import ContactPoint, Folder, NotificationPolicy, PMMRemoteAPI -from app.sep.main import sep_app - -_TEMPLATE_A = AlertTemplate( - name="High CPU", - service_type=ServiceType.GENERIC, - expression="cpu > 80", - default_threshold=80.0, - severity=AlertSeverity.WARNING, - description="CPU usage is above threshold.", - summary="High CPU on {{ $labels.instance }}", -) - -_TEMPLATE_B = AlertTemplate( - name="Disk Full", - service_type=ServiceType.GENERIC, - expression="disk_used_percent > 90", - default_threshold=90.0, - severity=AlertSeverity.CRITICAL, - description="Disk usage is above threshold.", - summary="Disk full on {{ $labels.instance }}", -) - -_ALERT_TEMPLATES: Mapping[ServiceType, tuple[AlertTemplate, ...]] = { - ServiceType.GENERIC: (_TEMPLATE_A, _TEMPLATE_B), - ServiceType.MYSQL: (), - ServiceType.MONGODB: (), - ServiceType.POSTGRESQL: (), -} - -_POPULATED_CONTEXT = { - "user": "test-user", - "all_templates": [_TEMPLATE_A], - "service_types": list(ServiceType), - "pmm_present_names": {"High CPU"}, - "alert_templates": {}, - "recent_backups": [], - "pagerduty_status": {"configured": False}, -} - -_EMPTY_CONTEXT = { - "user": "test-user", - "all_templates": [], - "service_types": list(ServiceType), - "pmm_present_names": None, - "alert_templates": {}, - "recent_backups": [], - "pagerduty_status": None, -} - -_FOLDER = Folder(uid="folder-1", title="SEP Alerts", id=1) - - -@pytest.fixture -def _mock_alerts_index_context(): - """Mock the alerts index context dependency with populated data.""" - sep_app.dependency_overrides[get_alerts_index_context] = lambda: _POPULATED_CONTEXT - yield - sep_app.dependency_overrides = {} - - -@pytest.fixture -def _mock_alerts_empty_context(): - """Mock the alerts index context dependency with empty data.""" - sep_app.dependency_overrides[get_alerts_index_context] = lambda: _EMPTY_CONTEXT - yield - sep_app.dependency_overrides = {} - - -@pytest.fixture -def mock_pmm_api(): - """Return a mock PMMRemoteAPI and wire it into dependency overrides.""" - mock = AsyncMock(spec=PMMRemoteAPI) - mock.list_folders.return_value = [_FOLDER] - mock.list_templates.return_value = [] - mock.create_template.return_value = AsyncMock() - mock.create_rule.return_value = AsyncMock() - sep_app.dependency_overrides[get_pmm_api] = lambda: mock - sep_app.dependency_overrides[get_alert_templates] = lambda: _ALERT_TEMPLATES - sep_app.dependency_overrides[get_or_create_alert_folder] = lambda: _FOLDER - sep_app.dependency_overrides[get_pmm_present_names] = lambda: set() - yield mock - sep_app.dependency_overrides = {} - - -@pytest.fixture -def _mock_pmm_unavailable(): - """Override PMM API dependency to return None.""" - sep_app.dependency_overrides[get_pmm_api] = lambda: None - yield - sep_app.dependency_overrides = {} - - -@pytest.fixture -def _mock_pmm_not_configured(): - """Mock PMM as not configured (returns None).""" - sep_app.dependency_overrides[get_pmm_api] = lambda: None - sep_app.dependency_overrides[get_alert_templates] = lambda: _ALERT_TEMPLATES - sep_app.dependency_overrides[get_or_create_alert_folder] = lambda: None - sep_app.dependency_overrides[get_pmm_present_names] = lambda: None - yield - sep_app.dependency_overrides = {} - - -@pytest.mark.usefixtures("_mock_alerts_index_context") -def test_alerts_index(test_client): - """Assert GET /alerts/ returns 200 with the alert templates page.""" - response = test_client.get("/alerts/") - assert response.status_code == status.HTTP_200_OK - assert response.headers["content-type"] == "text/html; charset=utf-8" - assert "Alert Templates" in response.text - - -@pytest.mark.usefixtures("_mock_alerts_index_context") -def test_alerts_index_contains_template_name(test_client): - """Assert the response includes the alert template name.""" - response = test_client.get("/alerts/") - assert "High CPU" in response.text - - -@pytest.mark.usefixtures("_mock_alerts_index_context") -def test_alerts_index_contains_filter_tabs(test_client): - """Assert the response includes service filter tabs.""" - response = test_client.get("/alerts/") - assert "Generic" in response.text - assert "MySQL" in response.text - - -@pytest.mark.usefixtures("_mock_alerts_empty_context") -def test_alerts_index_empty_state(test_client): - """Assert the empty state message renders when no templates exist.""" - response = test_client.get("/alerts/") - assert response.status_code == status.HTTP_200_OK - assert "No alert templates found." in response.text - assert "alerts-table" not in response.text - - -@pytest.mark.usefixtures("_mock_alerts_index_context") -def test_alerts_index_contains_pagerduty_widget(test_client): - """Assert the PagerDuty widget renders in the sidebar.""" - response = test_client.get("/alerts/") - assert "pagerduty-widget" in response.text - assert "PagerDuty Integration" in response.text - - -class TestPagerDutySave: - """Test the POST /alerts/pagerduty endpoint.""" - - def test_create_new_contact_point(self, test_client, mock_pmm_api): - """Assert a new contact point is created when none exists.""" - mock_pmm_api.list_contact_points.return_value = [] - mock_pmm_api.create_contact_point.return_value = ContactPoint( - uid="new-cp", name="SEP PagerDuty", type="pagerduty", settings={} - ) - mock_pmm_api.get_notification_policy.return_value = NotificationPolicy( - receiver="default", routes=[] - ) - - response = test_client.post( - "/alerts/pagerduty", - data={"integration_key": "test-key-abcd1234"}, - ) - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["status"] == "created" - assert "masked_key" not in data - mock_pmm_api.create_contact_point.assert_awaited_once() - - def test_update_existing_contact_point(self, test_client, mock_pmm_api): - """Assert an existing contact point is updated.""" - mock_pmm_api.list_contact_points.return_value = [ - ContactPoint( - uid="existing-cp", - name="SEP PagerDuty", - type="pagerduty", - settings={"integrationKey": "old-key"}, - ), - ] - mock_pmm_api.get_notification_policy.return_value = NotificationPolicy( - receiver="default", - routes=[{"receiver": "SEP PagerDuty"}], - ) - - response = test_client.post( - "/alerts/pagerduty", - data={"integration_key": "new-key-efgh5678"}, - ) - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["status"] == "updated" - assert "masked_key" not in data - mock_pmm_api.update_contact_point.assert_awaited_once() - - @pytest.mark.usefixtures("_mock_pmm_unavailable") - def test_returns_503_when_pmm_unavailable(self, test_client): - """Assert 503 is returned when PMM is not configured.""" - response = test_client.post( - "/alerts/pagerduty", - data={"integration_key": "some-key"}, - ) - assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE - - def test_returns_502_on_api_error(self, test_client, mock_pmm_api): - """Assert 502 is returned when PMM API raises an exception.""" - mock_pmm_api.list_contact_points.side_effect = OSError("API failure") - - response = test_client.post( - "/alerts/pagerduty", - data={"integration_key": "some-key"}, - ) - assert response.status_code == status.HTTP_502_BAD_GATEWAY - - -class TestPagerDutyDelete: - """Test the POST /alerts/pagerduty/delete endpoint.""" - - def test_deletes_contact_point_and_route(self, test_client, mock_pmm_api): - """Assert the contact point and route are deleted.""" - mock_pmm_api.list_contact_points.return_value = [ - ContactPoint( - uid="cp-1", - name="SEP PagerDuty", - type="pagerduty", - settings={"integrationKey": "key"}, - ), - ] - mock_pmm_api.get_notification_policy.return_value = NotificationPolicy( - receiver="default", - routes=[ - {"receiver": "SEP PagerDuty"}, - {"receiver": "other"}, - ], - ) - - response = test_client.post("/alerts/pagerduty/delete") - assert response.status_code == status.HTTP_200_OK - assert response.json()["status"] == "deleted" - updated_policy = mock_pmm_api.update_notification_policy.call_args[0][0] - assert len(updated_policy.routes) == 1 - assert updated_policy.routes[0]["receiver"] == "other" - mock_pmm_api.delete_contact_point.assert_awaited_once_with("cp-1") - call_names = [c[0] for c in mock_pmm_api.method_calls] - assert call_names.index("update_notification_policy") < call_names.index( - "delete_contact_point" - ) - - def test_returns_404_when_not_configured(self, test_client, mock_pmm_api): - """Assert 404 is returned when no PD contact point exists.""" - mock_pmm_api.list_contact_points.return_value = [] - - response = test_client.post("/alerts/pagerduty/delete") - assert response.status_code == status.HTTP_404_NOT_FOUND - - @pytest.mark.usefixtures("_mock_pmm_unavailable") - def test_returns_503_when_pmm_unavailable(self, test_client): - """Assert 503 is returned when PMM is not configured.""" - response = test_client.post("/alerts/pagerduty/delete") - assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE - - def test_returns_502_on_api_error(self, test_client, mock_pmm_api): - """Assert 502 is returned when PMM API raises an exception.""" - mock_pmm_api.list_contact_points.side_effect = OSError("API failure") - - response = test_client.post("/alerts/pagerduty/delete") - assert response.status_code == status.HTTP_502_BAD_GATEWAY - - -class TestAlertsPush: - """Test the POST /alerts/push endpoint.""" - - _EXPECTED_PUSH_COUNT = 2 - - def test_push_success(self, test_client, mock_pmm_api): - """Assert pushing templates returns per-template success results.""" - response = test_client.post( - "/alerts/push", - data={"selected_templates": ["High CPU", "Disk Full"]}, - ) - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert len(data["results"]) == self._EXPECTED_PUSH_COUNT - assert all(r["status"] == "success" for r in data["results"]) - assert mock_pmm_api.create_template.await_count == self._EXPECTED_PUSH_COUNT - assert mock_pmm_api.create_rule.await_count == self._EXPECTED_PUSH_COUNT - - @pytest.mark.usefixtures("_mock_pmm_not_configured") - def test_push_pmm_not_configured(self, test_client): - """Assert 503 is returned when PMM is not configured.""" - response = test_client.post( - "/alerts/push", - data={"selected_templates": ["High CPU"]}, - ) - assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE - assert response.json()["detail"] == "PMM is not configured" - - def test_push_already_present(self, test_client, mock_pmm_api): - """Assert templates already in PMM are skipped.""" - sep_app.dependency_overrides[get_pmm_present_names] = lambda: {"High CPU"} - - response = test_client.post( - "/alerts/push", - data={"selected_templates": ["High CPU"]}, - ) - assert response.status_code == status.HTTP_200_OK - result = response.json()["results"][0] - assert result["status"] == "skipped" - assert result["message"] == "Already present in PMM" - mock_pmm_api.create_template.assert_not_awaited() - - def test_push_template_not_found(self, test_client, mock_pmm_api): - """Assert an error result for a template name that does not exist.""" - response = test_client.post( - "/alerts/push", - data={"selected_templates": ["Nonexistent Template"]}, - ) - assert response.status_code == status.HTTP_200_OK - result = response.json()["results"][0] - assert result["status"] == "error" - assert result["message"] == "Template not found" - - def test_push_pmm_api_error(self, test_client, mock_pmm_api): - """Assert per-template error when PMM API raises an exception.""" - mock_pmm_api.create_template.side_effect = HTTPException( - status_code=502, detail="Bad Gateway" - ) - - response = test_client.post( - "/alerts/push", - data={"selected_templates": ["High CPU"]}, - ) - assert response.status_code == status.HTTP_200_OK - result = response.json()["results"][0] - assert result["status"] == "error" - assert "Bad Gateway" in result["message"] - - def test_push_returns_502_when_folder_unavailable(self, test_client, mock_pmm_api): - """Assert 502 is returned when the alert folder cannot be resolved.""" - sep_app.dependency_overrides[get_or_create_alert_folder] = lambda: None - - response = test_client.post( - "/alerts/push", - data={"selected_templates": ["High CPU"]}, - ) - assert response.status_code == status.HTTP_502_BAD_GATEWAY - assert response.json()["detail"] == "Failed to access PMM alert folder" - - def test_push_rule_failure_reports_orphaned_template( - self, test_client, mock_pmm_api - ): - """Assert error message indicates template was created when rule fails.""" - mock_pmm_api.create_rule.side_effect = HTTPException( - status_code=502, detail="Rule creation failed" - ) - - response = test_client.post( - "/alerts/push", - data={"selected_templates": ["High CPU"]}, - ) - assert response.status_code == status.HTTP_200_OK - result = response.json()["results"][0] - assert result["status"] == "error" - assert "Template created but rule failed" in result["message"] diff --git a/tests/app/sep/apps/alters/test_deps.py b/tests/app/sep/apps/alters/test_deps.py index 2c8b992e20..9eab816f87 100644 --- a/tests/app/sep/apps/alters/test_deps.py +++ b/tests/app/sep/apps/alters/test_deps.py @@ -26,17 +26,13 @@ from app.sep.apps.alters.deps import ( alters_executor_matches_service_host, alters_satellite_task_names, - AltersLegacyForm, build_alters_api_task_response, build_alters_task, build_pre_checks_task_payload, cascade_create_alters_group, cascade_update_alters_group, extract_service_info, - get_alters_index_context, get_alters_task, - get_alters_task_info, - map_alters_legacy_form, parse_alters_task_args, resolve_predecessor_specs, ) @@ -113,16 +109,6 @@ async def test_get_alters_task(created_task, mock_remote_api): assert alters_task.name == created_task.name -def test_get_alters_task_info(created_task): - """Test for extracting relevant information from a task for the Alters plugin.""" - result = get_alters_task_info(created_task.model_dump()) - assert result == { - "hostname": "localhost", - "table": "public.example_table", - "parent": None, - } - - def test_extract_service_info_from_meta(): """Test extracting service info from meta fields.""" meta = { @@ -471,30 +457,6 @@ def test_parse_alters_task_args_recursion_dsn_only_host_port(): assert out["dsn_table"] == "h=1,P=2" -@pytest.mark.asyncio -async def test_get_alters_index_context(mocker): - """Index view context is built via get_tasks_context.""" - merged = {"ok": True} - mock_ctx = mocker.patch( - "app.sep.apps.alters.deps.get_tasks_context", - new_callable=AsyncMock, - return_value=merged, - ) - inv, tasks, ctx, hosts = AsyncMock(), AsyncMock(), {"user": "u"}, {} - result = await get_alters_index_context(inv, tasks, ctx, hosts) - assert result is merged - mock_ctx.assert_awaited_once_with( - inv, - tasks, - get_alters_task_info, - hosts, - ctx, - "ALTERS", - service_type=ServiceTypeEnum.MYSQL, - alert_on_fail_default=True, - ) - - EXPECTED_CASCADE_CREATE_POSTS = 3 @@ -854,80 +816,3 @@ def _legacy_form_base() -> dict[str, object]: "alter": "ADD COLUMN x INT", "recursion_method": "processlist", } - - -class TestMapAltersLegacyForm: - """Test folding the split legacy Jinja form into the collapsed ``AltersCreate``.""" - - def test_collapse_prefers_inventory_id_over_manual_name(self): - """An inventory id wins over a manually-typed name for the same target.""" - form = AltersLegacyForm.model_validate( - { - **_legacy_form_base(), - "schema_id": _LEGACY_SCHEMA_ID, - "schema_name": "ignored", - "table_id": _LEGACY_TABLE_ID, - "table_name": "ignored", - } - ) - create = map_alters_legacy_form(form) - assert create.db_schema == _LEGACY_SCHEMA_ID - assert create.db_table == _LEGACY_TABLE_ID - - def test_collapse_uses_free_typed_name_when_no_id(self): - """A manually-typed (and whitespace-padded) name collapses to the trimmed value.""" - form = AltersLegacyForm.model_validate( - {**_legacy_form_base(), "schema_name": " app ", "table_name": " users "} - ) - create = map_alters_legacy_form(form) - assert create.db_schema == "app" - assert create.db_table == "users" - - def test_missing_both_target_modes_raises_422(self): - """Neither an id nor a name for the target folds to a 422, not a crash.""" - form = AltersLegacyForm.model_validate(_legacy_form_base()) - with pytest.raises(HTTPException) as exc_info: - map_alters_legacy_form(form) - assert exc_info.value.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - - def test_free_typed_name_with_dsn_delimiter_raises_422(self): - """Fold a free-typed legacy name with a DSN delimiter to a 422, not a bad DSN.""" - form = AltersLegacyForm.model_validate( - {**_legacy_form_base(), "schema_name": "a,b", "table_name": "users"} - ) - with pytest.raises(HTTPException) as exc_info: - map_alters_legacy_form(form) - assert exc_info.value.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - - def test_whitespace_only_name_collapses_to_none_and_raises_422(self): - """Collapse a whitespace-only legacy name to None (no target) → 422.""" - form = AltersLegacyForm.model_validate( - {**_legacy_form_base(), "schema_name": " ", "table_name": " "} - ) - with pytest.raises(HTTPException) as exc_info: - map_alters_legacy_form(form) - assert exc_info.value.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - - -class TestAltersLegacyFormCheckboxes: - """Test HTML checkbox binding on the legacy form (absent vs ``"1"``/``"true"``).""" - - def test_absent_checkbox_defaults_to_false(self): - """An unchecked box (field omitted from the POST body) defaults to ``False``.""" - form = AltersLegacyForm.model_validate(_legacy_form_base()) - assert form.print_arg is False - assert form.no_swap_tables is False - - def test_checkbox_value_one_is_true(self): - """A checked box submitting ``"1"`` binds to ``True``.""" - form = AltersLegacyForm.model_validate( - {**_legacy_form_base(), "print_arg": "1"} - ) - assert form.print_arg is True - - def test_checkbox_value_true_is_true(self): - """A checked box submitting ``"true"`` binds to ``True``.""" - form = AltersLegacyForm.model_validate( - {**_legacy_form_base(), "no_swap_tables": "true"} - ) - assert form.no_swap_tables is True diff --git a/tests/app/sep/apps/alters/test_payload_snapshot.py b/tests/app/sep/apps/alters/test_payload_snapshot.py index 238149cfbe..2e259362f8 100644 --- a/tests/app/sep/apps/alters/test_payload_snapshot.py +++ b/tests/app/sep/apps/alters/test_payload_snapshot.py @@ -32,9 +32,7 @@ from app.core.requests.remote_api import RemoteAPI from app.inventory.models import ServiceTypeEnum from app.sep.apps.alters.deps import ( - AltersLegacyForm, build_alters_task, - map_alters_legacy_form, ) from app.sep.apps.alters.models import AltersCreate from app.sep.apps.alters.spec import build_alters_spec @@ -193,47 +191,12 @@ async def _task_envelope(case: dict) -> dict: return task.model_dump() -async def _legacy_form_envelope(case: dict) -> dict: - """Return the legacy flat-form ``TaskWrite`` dump for ``case``. - - Drive the deprecated Jinja path end to end: fold the split legacy form into - an ``AltersCreate`` via ``map_alters_legacy_form``, then resolve and assemble - through the shared ``build_alters_task`` both create paths funnel into. - """ - service = _service(case) - inventory = AsyncMock(spec=RemoteAPI) - inventory.get = AsyncMock(return_value=service.model_dump()) - flat = AltersLegacyForm( - task_name=_TASK_NAME, - hostname=_HOSTNAME, - service_id=service.id, - schema_name="app", - table_name="users", - alter="ADD COLUMN new_col INT", - recursion_method=case["form"].get("recursion_method", "processlist"), - **{ - key: value - for key, value in case["form"].items() - if key != "recursion_method" - }, - ) - task = await build_alters_task(map_alters_legacy_form(flat), inventory) - return task.model_dump() - - def test_spec_path_payload_matrix_matches_golden(): """Assert the pure spec path reproduces the frozen envelope matrix.""" payloads = {case["slug"]: _spec_envelope(case) for case in _CASES} assert_or_update(PAYLOAD_DIR / "alters__payload.json", canonical_json(payloads)) -@pytest.mark.asyncio -async def test_legacy_form_path_payload_matrix_matches_golden(): - """Assert the legacy Jinja form path reproduces the frozen envelope matrix.""" - payloads = {case["slug"]: await _legacy_form_envelope(case) for case in _CASES} - assert_or_update(PAYLOAD_DIR / "alters__payload.json", canonical_json(payloads)) - - @pytest.mark.asyncio async def test_task_path_payload_matrix_matches_golden(): """Assert the inventory-resolving task path reproduces the frozen envelope matrix.""" diff --git a/tests/app/sep/apps/alters/test_routes.py b/tests/app/sep/apps/alters/test_routes.py deleted file mode 100644 index fdbfd8061a..0000000000 --- a/tests/app/sep/apps/alters/test_routes.py +++ /dev/null @@ -1,709 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <https://www.gnu.org/licenses/>. - -"""Define tests for the app.sep.apps.alters.routes module.""" - -from datetime import datetime, timedelta, UTC -from unittest.mock import AsyncMock, call, patch - -import pytest -from fastapi import HTTPException, status - -from app.inventory.models import ServiceTypeEnum -from app.sep.apps.alters.deps import ( - get_alters_index_context, - get_alters_task, - get_unprotected_alters_task, -) -from app.sep.apps.alters.models import AltersCreate -from app.sep.apps.alters.routes import router as alters_jinja_router -from app.sep.apps.framework.deprecation import DeprecatedJinja2Route -from app.sep.connectivity import ( - clear_connectivity_caches, - get_latest_connectivity_result, -) -from app.sep.main import sep_app -from app.tasks.models import ( - TaskBackendEnum, - TaskHistoryStatusEnum, -) -from tests.app.factories import GeneratedTaskFactory, TaskFactory -from tests.app.sep.apps.alters.factories import AltersCreateFactory - - -@pytest.fixture -def created_alters() -> AltersCreate: - """Return a fake created AltersCreate instance.""" - return AltersCreateFactory.build() - - -def _legacy_form_data(alters: AltersCreate) -> dict: - """Render an AltersCreate as the deprecated Jinja form body (split target fields). - - The Jinja create/update routes bind ``AltersLegacyForm`` (``schema_id`` / - ``schema_name`` / ``table_id`` / ``table_name``), so the collapsed - ``db_schema`` / ``db_table`` are expanded back into the split pair an id - populates ``*_id`` and a free-typed name populates ``*_name``. - """ - data = alters.model_dump() - db_schema = data.pop("db_schema") - db_table = data.pop("db_table") - if isinstance(db_schema, int): - data["schema_id"] = db_schema - else: - data["schema_name"] = db_schema - if isinstance(db_table, int): - data["table_id"] = db_table - else: - data["table_name"] = db_table - return data - - -@pytest.fixture -def _mock_alters_task_payload(generated_task, created_alters): - """Mock build_alters_task used by create/update Jinja routes.""" - task = generated_task.model_copy(update={"name": created_alters.task_name}) - with patch( - "app.sep.apps.alters.routes.build_alters_task", - new=AsyncMock(return_value=task), - ): - yield - - -@pytest.fixture -def created_task(): - """Return a fake created Task instance.""" - return TaskFactory.build(owner="ALTERS") - - -@pytest.fixture -def _mock_get_alters_task_dep(created_task): - """Mock the TaskDep dependency.""" - sep_app.dependency_overrides[get_alters_task] = lambda: created_task - sep_app.dependency_overrides[get_unprotected_alters_task] = lambda: created_task - yield - sep_app.dependency_overrides = {} - - -@pytest.fixture -def _mock_get_alters_index_context_dep(): - """Mock the get_alters_index_context dependency with default user context.""" - sep_app.dependency_overrides[get_alters_index_context] = lambda: { - "user": "default_user", - "connectivity_check_default": True, - } - yield - sep_app.dependency_overrides = {} - - -@pytest.mark.usefixtures("_mock_get_alters_index_context_dep") -def test_alters_index( - test_client, -): - """Test listing alters tasks.""" - response = test_client.get("/alters/") - assert response.status_code == status.HTTP_200_OK - assert response.headers["content-type"] == "text/html; charset=utf-8" - - -@pytest.mark.usefixtures("_mock_alters_task_payload") -def test_alters_create( - test_client, - mock_task_api_dep, - created_alters, - generated_task, -): - """Test creating a new alters task.""" - response = test_client.post( - "/alters/", data=_legacy_form_data(created_alters), follow_redirects=False - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert ( - response.headers["location"] - == f"{test_client.base_url}/alters/{created_alters.task_name}" - ) - - -EXPECTED_ALTERS_POST_CALLS = 3 - - -def test_alters_create_full_form_dependency_chain_without_payload_override( - test_client, - mock_task_api_dep, - mock_inventory_api_dep, - created_service, - created_schema, - created_table, -): - """Test POST /alters/ route without overriding build_alters_task.""" - created_alters = AltersCreateFactory.build() - created_alters.service_id = created_service.id - created_alters.db_schema = created_schema.id - created_alters.db_table = created_table.id - created_alters.alter = "ADD COLUMN new_column INT" - created_alters.recursion_method = "dsn" - created_alters.dsn_table = "D=percona,t=dsns" - created_alters.continue_on_pre_check_failure = False - - mock_inventory_api_dep.get = AsyncMock( - side_effect=[ - created_service.model_dump(), - created_schema.model_dump(), - created_table.model_dump(), - ] - ) - mock_task_api_dep.get = AsyncMock(return_value={}) - mock_task_api_dep.post.return_value = AsyncMock() - - response = test_client.post( - "/alters/", - data=_legacy_form_data(created_alters), - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"].endswith(f"/alters/{created_alters.task_name}") - assert mock_task_api_dep.post.call_count == EXPECTED_ALTERS_POST_CALLS - - -def test_alters_create_skips_connectivity_check_when_opted_out( - test_client, mock_task_api_dep, created_alters -): - """POST /alters/ skips the connectivity check when the checkbox is unchecked.""" - clear_connectivity_caches() - - fake_task_write = GeneratedTaskFactory.build( - name="fake_alter", - backend=TaskBackendEnum.PROXY, - owner="ALTERS", - data={ - "task": "run-command", - "meta": { - "command": "pt-online-schema-change", - "args": "--alter=ADD COLUMN c INT --execute", - "target": "node1", - "_schema_name": "db", - "_table_name": "t", - "_service_host": "10.0.0.1", - "_service_port": 3306, - "_connectivity_host": "10.0.0.1", - "_connectivity_port": 3306, - "_connectivity_service_type": ServiceTypeEnum.MYSQL.value, - }, - }, - ) - - with patch( - "app.sep.apps.alters.routes.build_alters_task", - new=AsyncMock(return_value=fake_task_write), - ): - mock_task_api_dep.get = AsyncMock(return_value={}) - - response = test_client.post( - "/alters/", data=_legacy_form_data(created_alters), follow_redirects=False - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert ( - response.headers["location"] - == f"{test_client.base_url}/alters/{fake_task_write.name}" - ) - - assert mock_task_api_dep.post.call_count == EXPECTED_ALTERS_POST_CALLS - first_call = mock_task_api_dep.post.call_args_list[0] - assert first_call.args[0] == "/" - assert first_call.kwargs["json"] == fake_task_write.model_dump() - assert get_latest_connectivity_result("node1", "mysql") is None - - clear_connectivity_caches() - - -@pytest.mark.parametrize( - ("nomad_hosts", "expect_skip_in_pre_checks_yaml"), - [ - ({"db1": "10.0.0.5"}, False), - ({"db1": "10.0.0.99"}, True), - ], -) -def test_alters_create_pre_checks_filesystem_skip_flag( - test_client, - mock_task_api_dep, - created_alters, - nomad_hosts, - expect_skip_in_pre_checks_yaml, -): - """pre-checks YAML skip_filesystem_checks follows executor vs DB host (Nomad /hosts/).""" - task = GeneratedTaskFactory.build( - name="sep764-alter", - data={ - "task": "run-command", - "meta": { - "command": "pt-online-schema-change", - "args": "--alter=ADD COLUMN c INT --execute", - "target": "db1", - "_schema_name": "db", - "_table_name": "t", - "_service_host": "10.0.0.5", - "_service_port": 3306, - }, - }, - backend=TaskBackendEnum.PROXY, - ) - with patch( - "app.sep.apps.alters.routes.build_alters_task", - new=AsyncMock(return_value=task), - ): - mock_task_api_dep.post.return_value = AsyncMock() - mock_task_api_dep.get = AsyncMock(return_value=nomad_hosts) - response = test_client.post( - "/alters/", data=_legacy_form_data(created_alters), follow_redirects=False - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - mock_task_api_dep.get.assert_awaited_once_with("/hosts/") - pre_checks_cfg = mock_task_api_dep.post.call_args_list[2].kwargs["json"][ - "data" - ]["meta"]["config"] - if expect_skip_in_pre_checks_yaml: - assert "skip_filesystem_checks: true" in pre_checks_cfg - else: - assert "skip_filesystem_checks" not in pre_checks_cfg - - -@pytest.mark.usefixtures("_mock_get_alters_task_dep", "mock_get_username_mapping") -def test_alters_detail( - test_client, - created_task, - mock_task_api_dep, - mock_inventory_api_dep, -): - """Test retrieving an alters' detail page.""" - mock_data = { - "task": "run-command", - "meta": { - "command": "pt-online-schema-change", - "args": "--alter=ADD COLUMN new_column INT --execute", - "target": "localhost", - "_schema_name": "public", - "_table_name": "example_table", - }, - } - created_task.data = mock_data - mock_inventory_api_dep.get.return_value = { - "items": [], - "total": 0, - "offset": 0, - "limit": 50, - } - mock_task_api_dep.get.side_effect = [ - {"address1": "host1", "address2": "host2"}, # for /hosts/ (dependency) - {"items": [], "total": 0, "offset": 0, "limit": 50}, - {"items": [], "total": 0, "offset": 0, "limit": 50}, - {"items": [], "total": 0, "offset": 0, "limit": 50}, - {"items": [], "total": 0, "offset": 0, "limit": 50}, - {"items": [], "total": 0, "offset": 0, "limit": 50}, - {"items": [], "total": 0, "offset": 0, "limit": 50}, - {}, - {"items": [], "total": 0, "offset": 0, "limit": 50}, # chainable_tasks - ] - expected_awaits = [ - call("/hosts/"), - call(f"/{created_task.name}/history/"), - call(f"/{created_task.name}-dry-run/history/"), - call(f"/{created_task.name}-pre-checks/history/"), - call( - f"/{created_task.name}/history/", - params={"status": TaskHistoryStatusEnum.RUNNING}, - ), - call( - f"/{created_task.name}-dry-run/history/", - params={"status": TaskHistoryStatusEnum.RUNNING}, - ), - call( - f"/{created_task.name}-pre-checks/history/", - params={"status": TaskHistoryStatusEnum.RUNNING}, - ), - call(f"/stats/{created_task.name}"), - call( - "/", - params={"owner": created_task.owner, "target": "localhost"}, - ), - ] - - response = test_client.get(f"/alters/{created_task.name}") - - assert response.status_code == status.HTTP_200_OK - assert created_task.name in response.text - assert mock_task_api_dep.get.await_count == len(expected_awaits) - mock_task_api_dep.get.assert_has_awaits(expected_awaits) - - -@pytest.mark.usefixtures( - "_mock_get_alters_task_dep", "_mock_check_for_conflicted_running_tasks" -) -def test_alters_execute( - test_client, - created_task, - mock_task_api_dep, -): - """Test executing a alters task.""" - created_task.data.pop("parent", None) - mock_task_api_dep.post.return_value = AsyncMock() - eta = datetime.now(tz=UTC) + timedelta(days=1) - response = test_client.post( - f"/alters/{created_task.name}", data={"eta": str(eta)}, follow_redirects=False - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert ( - response.headers["location"] - == f"{test_client.base_url}/alters/{created_task.name}" - ) - - -@pytest.mark.usefixtures("_mock_check_for_conflicted_running_tasks") -def test_alters_delete( - test_client, - created_task, - mock_task_api_dep, -): - """Test deleting a alters task.""" - mock_task_api_dep.get = AsyncMock(return_value=created_task.model_dump(mode="json")) - mock_task_api_dep.delete.return_value = AsyncMock() - - response = test_client.post( - f"/alters/{created_task.name}/delete", follow_redirects=False - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"] == "/alters" - mock_task_api_dep.delete.assert_has_awaits( - [ - call(f"/{created_task.name}-dry-run"), - call(f"/{created_task.name}-pre-checks"), - call(f"/{created_task.name}"), - ] - ) - - -@pytest.mark.usefixtures("_mock_check_for_conflicted_running_tasks") -def test_alters_delete_returns_303_for_protected_task( - test_client, - mock_task_api_dep, -) -> None: - """POST /alters/{task_name}/delete rejects protected tasks (303 redirect for forms).""" - protected = TaskFactory.build( - name="protected-alter", - owner="ALTERS", - protected=True, - ) - mock_task_api_dep.get = AsyncMock(return_value=protected.model_dump(mode="json")) - - response = test_client.post( - "/alters/protected-alter/delete", - follow_redirects=False, - ) - - assert response.status_code == status.HTTP_303_SEE_OTHER - mock_task_api_dep.delete.assert_not_called() - - -@pytest.mark.usefixtures("_mock_check_for_conflicted_running_tasks") -def test_alters_delete_surfaces_partial_cascade_failure( - test_client, - created_task, - mock_task_api_dep, -) -> None: - """POST delete flashes and redirects on partial cascade failure, not /alters.""" - task_name = created_task.name - mock_task_api_dep.get = AsyncMock(return_value=created_task.model_dump(mode="json")) - derived_exc = HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) - - async def _delete(path: str) -> None: - if path == f"/{task_name}-pre-checks": - raise derived_exc - - mock_task_api_dep.delete = AsyncMock(side_effect=_delete) - referer = f"{test_client.base_url}/alters/{task_name}" - - response = test_client.post( - f"/alters/{task_name}/delete", - headers={"referer": referer}, - follow_redirects=False, - ) - - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"] == referer - assert response.headers["location"] != "/alters" - - -def test_get_table_details( - test_client, - mock_inventory_api_dep, -): - """Test getting table details via XHR endpoint.""" - table_id = 123 - mock_table_data = { - "id": table_id, - "name": "test_table", - "create": "CREATE TABLE test_table (id INT PRIMARY KEY, name VARCHAR(255))", - "keys": {"PRIMARY": {"type": "PRIMARY", "columns": ["id"]}}, - } - mock_inventory_api_dep.get.side_effect = [mock_table_data] - - response = test_client.get(f"/alters/table/{table_id}/details") - - assert response.status_code == status.HTTP_200_OK - assert response.headers["content-type"] == "application/json" - - data = response.json() - assert data["id"] == table_id - assert data["name"] == "test_table" - assert data["create"] == mock_table_data["create"] - assert data["keys"] == mock_table_data["keys"] - - mock_inventory_api_dep.get.assert_awaited_once_with(f"/tables/{table_id}") - - -def test_get_table_details_with_syntax_highlight( - test_client, - mock_inventory_api_dep, -): - """syntax_highlight_style applies Pygments to the create statement.""" - table_id = 55 - create_sql = "CREATE TABLE t (id INT);" - mock_inventory_api_dep.get = AsyncMock( - return_value={ - "id": table_id, - "name": "t", - "create": create_sql, - "keys": {}, - } - ) - response = test_client.get( - f"/alters/table/{table_id}/details?syntax_highlight_style=default" - ) - assert response.status_code == status.HTTP_200_OK - body = response.json() - assert body["create"] != create_sql - assert "highlight" in body["create"] or "<span" in body["create"] - - -def test_get_table_details_inventory_error( - test_client, - mock_inventory_api_dep, -): - """Inventory failure returns JSON 500.""" - mock_inventory_api_dep.get = AsyncMock( - side_effect=HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="missing" - ) - ) - response = test_client.get("/alters/table/999/details") - assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR - assert response.json()["error"] == "Failed to fetch table details" - - -@pytest.mark.usefixtures("_mock_get_alters_task_dep", "mock_get_username_mapping") -def test_alters_detail_redirects_to_parent_task( - test_client, - created_task, - mock_task_api_dep, - mock_inventory_api_dep, -): - """Child task (dry-run / pre-checks) redirects to parent detail.""" - mock_task_api_dep.get = AsyncMock(return_value={}) - mock_inventory_api_dep.get = AsyncMock(return_value=[]) - created_task.name = "child-alter" - created_task.data = { - "parent": "parent-alter", - "task": "run-command", - "meta": { - "command": "pt-online-schema-change", - "args": "--execute", - "target": "localhost", - "_schema_name": "db", - "_table_name": "tbl", - }, - } - response = test_client.get("/alters/child-alter", follow_redirects=False) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"].endswith("/alters/parent-alter") - - -@pytest.mark.usefixtures("_mock_get_alters_task_dep", "mock_get_username_mapping") -def test_alters_detail_when_hosts_api_fails( - test_client, - created_task, - mock_task_api_dep, - mock_inventory_api_dep, -): - """Executor hosts failure yields empty host map; page still renders.""" - mock_data = { - "task": "run-command", - "meta": { - "command": "pt-online-schema-change", - "args": "--alter=x --execute", - "target": "localhost", - "_schema_name": "public", - "_table_name": "t", - }, - } - created_task.data = mock_data - - async def tasks_api_get(path: str, *args, **kwargs) -> object: - # Do not rely on call order of FastAPI deps — match by path. - if path == "/hosts/": - raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE) - if path.startswith("/stats/"): - return {} - if "/history/" in path or path == "/": - return {"items": [], "total": 0, "offset": 0, "limit": 50} - return [] - - mock_task_api_dep.get = AsyncMock(side_effect=tasks_api_get) - mock_inventory_api_dep.get = AsyncMock( - return_value={"items": [], "total": 0, "offset": 0, "limit": 50} - ) - response = test_client.get(f"/alters/{created_task.name}") - assert response.status_code == status.HTTP_200_OK - assert created_task.name in response.text - - -@pytest.mark.usefixtures("_mock_get_alters_task_dep", "mock_get_username_mapping") -def test_alters_detail_when_services_api_fails( - test_client, - created_task, - mock_task_api_dep, - mock_inventory_api_dep, -): - """MySQL services list failure yields empty services; page still renders.""" - mock_data = { - "task": "run-command", - "meta": { - "command": "pt-online-schema-change", - "args": "--execute", - "target": "localhost", - "_schema_name": "p", - "_table_name": "t", - }, - } - created_task.data = mock_data - - async def tasks_api_get_ok(path: str, *args, **kwargs) -> object: - if path == "/hosts/": - return {} - if path.startswith("/stats/"): - return {} - if "/history/" in path or path == "/": - return {"items": [], "total": 0, "offset": 0, "limit": 50} - return [] - - async def inventory_api_get(path: str, *args, **kwargs) -> object: - if path == "/": - return [] - if path.startswith("/services"): - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) - return [] - - mock_task_api_dep.get = AsyncMock(side_effect=tasks_api_get_ok) - mock_inventory_api_dep.get = AsyncMock(side_effect=inventory_api_get) - response = test_client.get(f"/alters/{created_task.name}") - assert response.status_code == status.HTTP_200_OK - - -@pytest.mark.parametrize( - ("nomad_hosts", "executor_hostname", "expect_skip_in_pre_checks_yaml"), - [ - ({}, "localhost", False), - ({"db1": "10.0.0.99"}, "db1", True), - ], -) -@pytest.mark.usefixtures("_mock_get_alters_task_dep") -def test_alters_update_refreshes_pre_checks_task( - test_client, - mock_task_api_dep, - mock_inventory_api_dep, - created_alters, - created_task, - created_service, - created_schema, - created_table, - nomad_hosts, - executor_hostname, - expect_skip_in_pre_checks_yaml, -): - """Update main/dry-run/pre-checks tasks; pre-checks YAML reflects executor vs DB.""" - created_task.name = created_alters.task_name - created_alters.service_id = created_service.id - created_alters.db_schema = created_schema.id - created_alters.db_table = created_table.id - created_alters.hostname = executor_hostname - mock_inventory_api_dep.get.side_effect = [ - created_service.model_dump(), - created_schema.model_dump(), - created_table.model_dump(), - ] - mock_task_api_dep.put.return_value = AsyncMock() - mock_task_api_dep.get = AsyncMock(return_value=nomad_hosts) - name = created_alters.task_name - response = test_client.post( - f"/alters/{name}/update", - data=_legacy_form_data(created_alters), - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - mock_task_api_dep.get.assert_awaited_once_with("/hosts/") - update_puts = mock_task_api_dep.put.call_args_list - assert len(update_puts) == len(["main", "dry_run", "pre_checks"]) - pre_checks_put = [c for c in update_puts if "pre-checks" in c.args[0]] - assert len(pre_checks_put) == 1 - cfg = pre_checks_put[0].kwargs["json"]["data"]["meta"]["config"] - assert "schema:" in cfg - assert "table:" in cfg - if expect_skip_in_pre_checks_yaml: - assert "skip_filesystem_checks: true" in cfg - else: - assert "skip_filesystem_checks" not in cfg - - -def test_alters_update_returns_409_for_protected_task( - test_client, - mock_task_api_dep, - created_alters, -) -> None: - """POST /alters/{task_name}/update rejects protected tasks (303 redirect for forms).""" - protected = TaskFactory.build( - name="protected-alter", - owner="ALTERS", - protected=True, - ) - mock_task_api_dep.get = AsyncMock(return_value=protected.model_dump(mode="json")) - - with patch( - "app.sep.apps.alters.routes.build_alters_task", - new=AsyncMock(), - ): - response = test_client.post( - "/alters/protected-alter/update", - data=_legacy_form_data(created_alters), - follow_redirects=False, - ) - - assert response.status_code == status.HTTP_303_SEE_OTHER - mock_task_api_dep.put.assert_not_called() - - -class TestAltersRouterDeprecation: - """The alters Jinja2 router uses the deprecation route class.""" - - def test_router_uses_deprecated_route_class(self): - """Confirm the router is constructed with ``DeprecatedJinja2Route``.""" - assert alters_jinja_router.route_class is DeprecatedJinja2Route diff --git a/tests/app/sep/apps/archives/test_deps.py b/tests/app/sep/apps/archives/test_deps.py deleted file mode 100644 index 6e8982467b..0000000000 --- a/tests/app/sep/apps/archives/test_deps.py +++ /dev/null @@ -1,200 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <https://www.gnu.org/licenses/>. - -"""Test the hand-written parts of the archives ``deps`` module. - -The model-first create / update / list / detail surfaces are covered by -``test_api`` and ``test_payload_snapshot``; this file covers the legacy -flat-form → one-of mapping and the Jinja index's task-info extraction. -""" - -from typing import Any -from unittest.mock import AsyncMock - -import pytest -import yaml - -from app.core.exceptions import HTTPUnprocessableEntityException -from app.core.requests.remote_api import RemoteAPI -from app.core.utils.path import resolve_payload_reference -from app.inventory.models import ServiceTypeEnum -from app.sep.apps.archives.constants import SwapDropEnum -from app.sep.apps.archives.deps import ( - ArchivesLegacyForm, - build_archives_task_payload, - get_archives_task_info, -) -from tests.app.factories import ( - CreatedNodeFactory, - CreatedSchemaFactory, - CreatedServiceFactory, - CreatedTableFactory, -) - -_SRC_DB_ID = 10 -_SRC_TBL_ID = 20 -_DEST_TBL_ID = 30 - - -def _legacy(**overrides: Any) -> ArchivesLegacyForm: - """Build a flat legacy form with a table source + table destination.""" - body: dict[str, Any] = { - "alias": "arch", - "hostname": "exec-host", - "service_id": 1, - "source_db_id": _SRC_DB_ID, - "source_table_id": _SRC_TBL_ID, - "dest_table_id": _DEST_TBL_ID, - "swap_drop": SwapDropEnum.PURGE_ONLY.value, - "where": "id < 100", - } - body.update(overrides) - return ArchivesLegacyForm.model_validate(body) - - -def _fake_inventory() -> AsyncMock: - """Return an inventory mock resolving the seeded source/dest ids by path.""" - routes = { - "/services/1": CreatedServiceFactory.build( - id=1, - node=CreatedNodeFactory.build(address="src-host", name="src-node"), - type=ServiceTypeEnum.MYSQL, - name="src-svc", - port=3306, - ).model_dump(mode="json"), - f"/schemas/{_SRC_DB_ID}": CreatedSchemaFactory.build( - id=_SRC_DB_ID, name="src_db" - ).model_dump(mode="json"), - f"/tables/{_SRC_TBL_ID}": CreatedTableFactory.build( - id=_SRC_TBL_ID, name="src_tbl" - ).model_dump(mode="json"), - f"/tables/{_DEST_TBL_ID}": CreatedTableFactory.build( - id=_DEST_TBL_ID, name="dst_tbl" - ).model_dump(mode="json"), - } - api = AsyncMock(spec=RemoteAPI) - - async def _get(path: str, params: dict | None = None) -> dict: - return routes[path] - - api.get.side_effect = _get - return api - - -def _purge_item(task: Any) -> dict[str, Any]: - """Return the single PURGE_LIST item from a built task's config.""" - return yaml.safe_load(task.data["meta"]["config"])["PURGE_LIST"][0] - - -class TestLegacyFormPayload: - """Cover the flat Jinja form folded into the one-of model via the public dep.""" - - @pytest.mark.asyncio - async def test_table_path_collapses_ids(self) -> None: - """Assert the flat id/name pairs collapse into the resolved config names.""" - task = await build_archives_task_payload(_legacy(), _fake_inventory()) - item = _purge_item(task) - assert item["SOURCE_DB"] == "src_db" - assert item["SOURCE_TABLE"] == "src_tbl" - assert item["DEST_TABLE"] == "dst_tbl" - assert task.name == "arch" - assert task.data["payload"] == "file://app/sep/apps/archives/payload" - assert resolve_payload_reference(task.data["payload"]).is_file() - - @pytest.mark.asyncio - async def test_manual_names(self) -> None: - """Assert the free-typed names are used when no inventory id is supplied.""" - task = await build_archives_task_payload( - _legacy( - source_db_id="", - source_db_name="mydb", - source_table_id="", - source_table_name="mytbl", - ), - _fake_inventory(), - ) - item = _purge_item(task) - assert item["SOURCE_DB"] == "mydb" - assert item["SOURCE_TABLE"] == "mytbl" - - @pytest.mark.asyncio - async def test_query_path(self) -> None: - """Assert a source query folds into the query branch.""" - task = await build_archives_task_payload( - _legacy(source_db_id="", source_table_id="", source_query="SELECT 1"), - _fake_inventory(), - ) - item = _purge_item(task) - assert item["SOURCE_QUERY"] == "SELECT 1" - assert "SOURCE_DB" not in item - - @pytest.mark.asyncio - async def test_invalid_swap_drop_raises_422(self) -> None: - """Assert a folded-model validation failure surfaces as a 422.""" - with pytest.raises(HTTPUnprocessableEntityException): - await build_archives_task_payload( - _legacy(swap_drop=SwapDropEnum.SWAP_DROP.value), AsyncMock() - ) - - -class TestGetArchivesTaskInfo: - """Cover the Jinja index's task-info extraction from a task's config.""" - - @staticmethod - def _task( - purge_item: dict[str, Any], hostname: str = "mock_target" - ) -> dict[str, Any]: - """Build a minimal task dict carrying a one-item PURGE_LIST config.""" - return { - "data": { - "meta": { - "config": yaml.dump({"PURGE_LIST": [purge_item]}), - "target": hostname, - } - }, - "created_by": None, - "last_updated_by": None, - } - - def test_source_and_dest_table(self) -> None: - """Extract the source and destination tables and the hostname.""" - result = get_archives_task_info( - self._task( - {"SOURCE_DB": "mydb", "SOURCE_TABLE": "src", "DEST_TABLE": "arch"} - ) - ) - assert result["hostname"] == "mock_target" - assert result["source_table"] == "mydb.src" - assert result["dest_table"] == "mydb.arch" - - def test_source_query(self) -> None: - """Extract a source query and omit the source table.""" - result = get_archives_task_info(self._task({"SOURCE_QUERY": "SELECT 1"})) - assert result["source_query"] == "SELECT 1" - assert "source_table" not in result - - def test_dest_file(self) -> None: - """Extract a destination file path.""" - result = get_archives_task_info( - self._task( - {"SOURCE_DB": "d", "SOURCE_TABLE": "t", "DEST_FILE": "/tmp/a.csv"} - ) - ) - assert result["dest_file"] == "/tmp/a.csv" - - def test_no_source_table(self) -> None: - """Omit the source table when neither source db nor table is set.""" - result = get_archives_task_info(self._task({"ALIAS": "x", "SWAP_DROP": 0})) - assert "source_table" not in result diff --git a/tests/app/sep/apps/archives/test_payload_snapshot.py b/tests/app/sep/apps/archives/test_payload_snapshot.py deleted file mode 100644 index 8ce91b42bd..0000000000 --- a/tests/app/sep/apps/archives/test_payload_snapshot.py +++ /dev/null @@ -1,250 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <https://www.gnu.org/licenses/>. - -"""Pin the archives task payload and assert the JSON and Jinja paths agree. - -The spec-path matrix builds the ``run-python`` envelope directly through -:func:`build_archives_spec` + ``assemble_envelope`` (the model-first JSON create -path) over the source / destination / host permutations, and the form-path test -drives the same case through the legacy flat-form dependency -:func:`build_archives_task_payload` — so a form-created task's payload stays -byte-identical to a JSON-created one. -""" - -from typing import Any -from unittest.mock import AsyncMock - -import pytest - -from app.core.requests.remote_api import RemoteAPI -from app.inventory.models import ServiceTypeEnum -from app.sep.apps.archives.deps import ( - ArchivesLegacyForm, - build_archives_task_payload, -) -from app.sep.apps.archives.models import ArchivesCreate -from app.sep.apps.archives.spec import build_archives_spec -from app.sep.apps.framework.spec import assemble_envelope, ResolvedEntities -from app.sep.inventory import CreatedService -from tests.app.factories import ( - CreatedNodeFactory, - CreatedSchemaFactory, - CreatedServiceFactory, - CreatedTableFactory, -) -from tests.app.sep.snapshot_utils import assert_or_update, canonical_json, SNAPSHOTS_DIR - -GOLDEN = SNAPSHOTS_DIR / "payload" / "archives__payload.json" -_PAYLOAD_ANCHOR = "app/sep/apps/archives/" -_HOSTNAME = "executor-host" - -_SOURCE_SERVICE = CreatedServiceFactory.build( - id=1, - node=CreatedNodeFactory.build(address="src-host", name="src-node"), - type=ServiceTypeEnum.MYSQL, - name="src-svc", - port=3306, -) -_DEST_SERVICE = CreatedServiceFactory.build( - id=2, - node=CreatedNodeFactory.build(address="dst-host", name="dst-node"), - type=ServiceTypeEnum.MYSQL, - name="dst-svc", - port=3307, -) -_SOURCE_SCHEMA = CreatedSchemaFactory.build(id=1, name="src_db") -_SOURCE_TABLE = CreatedTableFactory.build(id=1, name="src_table") -_DEST_TABLE = CreatedTableFactory.build(id=2, name="dst_table") -_DEST_SCHEMA = CreatedSchemaFactory.build(id=2, name="dst_db") - - -def _normalize(envelope: dict[str, Any]) -> dict[str, Any]: - """Rewrite the absolute ``file://`` payload path to a package-relative anchor.""" - payload = envelope["data"]["payload"] - suffix = payload.split(_PAYLOAD_ANCHOR)[-1] - envelope["data"]["payload"] = f"file://{_PAYLOAD_ANCHOR}{suffix}" - return envelope - - -def _envelope(form: ArchivesCreate, resolved: ResolvedEntities) -> dict[str, Any]: - """Build the normalized run-python envelope for the spec (JSON) path.""" - task = assemble_envelope( - build_archives_spec(form, resolved), - resolved, - name=form.task_name, - owner="ARCHIVER", - alert_on_fail=form.alert_on_fail, - alert_detail_builder="app.sep.apps.archives.alerts:build_owner_alert_details", - ) - return _normalize(task.model_dump()) - - -# Each case: (slug, ArchivesCreate body kwargs, resolved entities by dotted key). -_CASES: list[tuple[str, dict[str, Any], dict[str, CreatedService | None]]] = [ - ( - "table_to_table_same_host", - { - "task_name": "arch-1", - "hostname": _HOSTNAME, - "service_id": 1, - "source": {"mode": "table", "source_db": 1, "source_table": 1}, - "destination": {"mode": "table", "dest_table": 2}, - "where": "id < 100", - }, - { - "source.source_db": _SOURCE_SCHEMA, - "source.source_table": _SOURCE_TABLE, - "destination.dest_table": _DEST_TABLE, - "destination.dest_db": None, - }, - ), - ( - "table_to_file", - { - "task_name": "arch-2", - "hostname": _HOSTNAME, - "service_id": 1, - "source": {"mode": "table", "source_db": 1, "source_table": 1}, - "destination": {"mode": "file", "dest_file": "/data/out.csv"}, - "where": "id < 100", - "disable_bulk_insert": True, - }, - {"source.source_db": _SOURCE_SCHEMA, "source.source_table": _SOURCE_TABLE}, - ), - ( - "query_to_table", - { - "task_name": "arch-3", - "hostname": _HOSTNAME, - "service_id": 1, - "source": {"mode": "query", "source_query": "SELECT * FROM t"}, - "destination": {"mode": "table", "dest_table": 2, "dest_db": 2}, - "where": "id < 100", - }, - {"destination.dest_table": _DEST_TABLE, "destination.dest_db": _DEST_SCHEMA}, - ), - ( - "dest_service_host", - { - "task_name": "arch-4", - "hostname": _HOSTNAME, - "service_id": 1, - "source": {"mode": "table", "source_db": 1, "source_table": 1}, - "destination": {"mode": "table", "dest_table": 2}, - "host": {"mode": "service", "dest_service": 2}, - "where": "id < 100", - }, - { - "source.source_db": _SOURCE_SCHEMA, - "source.source_table": _SOURCE_TABLE, - "destination.dest_table": _DEST_TABLE, - "host.dest_service": _DEST_SERVICE, - }, - ), - ( - "manual_host_free_solo_names", - { - "task_name": "arch-5", - "hostname": _HOSTNAME, - "service_id": 1, - "source": { - "mode": "table", - "source_db": "typed_db", - "source_table": "typed_tbl", - }, - "destination": {"mode": "table", "dest_table": "typed_dest"}, - "host": {"mode": "manual", "dest_host": "remote-host", "dest_port": 3309}, - "where": "id < 100", - "use_index": "idx_a", - "limit": 500, - "sleep": 1, - }, - { - "source.source_db": None, - "source.source_table": None, - "destination.dest_table": None, - }, - ), - ( - "delete_data_no_dest", - { - "task_name": "arch-6", - "hostname": _HOSTNAME, - "service_id": 1, - "source": {"mode": "table", "source_db": 1, "source_table": 1}, - "delete_data": True, - "where": "id < 100", - }, - {"source.source_db": _SOURCE_SCHEMA, "source.source_table": _SOURCE_TABLE}, - ), -] - - -def test_spec_path_payload_matrix_matches_golden() -> None: - """Pin the model-first JSON create payload across the source/dest/host matrix.""" - payloads = {} - for slug, body, entities in _CASES: - form = ArchivesCreate.model_validate(body) - resolved = ResolvedEntities( - service=_SOURCE_SERVICE, entities=entities, executor_host=_HOSTNAME - ) - payloads[slug] = _envelope(form, resolved) - assert_or_update(GOLDEN, canonical_json(payloads)) - - -def _fake_inventory() -> AsyncMock: - """Return an inventory mock resolving the seeded source/dest entities by path.""" - routes = { - "/services/1": _SOURCE_SERVICE.model_dump(mode="json"), - "/services/2": _DEST_SERVICE.model_dump(mode="json"), - "/schemas/1": _SOURCE_SCHEMA.model_dump(mode="json"), - "/schemas/2": _DEST_SCHEMA.model_dump(mode="json"), - "/tables/1": _SOURCE_TABLE.model_dump(mode="json"), - "/tables/2": _DEST_TABLE.model_dump(mode="json"), - } - api = AsyncMock(spec=RemoteAPI) - - async def _get(path: str, params: dict | None = None) -> dict: - return routes[path] - - api.get.side_effect = _get - return api - - -@pytest.mark.asyncio -async def test_form_path_matches_spec_path() -> None: - """Assert the legacy flat-form path yields the same payload as the JSON path.""" - spec_form = ArchivesCreate.model_validate(_CASES[0][1]) - spec_resolved = ResolvedEntities( - service=_SOURCE_SERVICE, entities=_CASES[0][2], executor_host=_HOSTNAME - ) - spec_payload = _envelope(spec_form, spec_resolved) - - flat = ArchivesLegacyForm.model_validate( - { - "alias": "arch-1", - "hostname": _HOSTNAME, - "service_id": 1, - "source_db_id": 1, - "source_table_id": 1, - "dest_table_id": 2, - "swap_drop": 0, - "where": "id < 100", - } - ) - form_task = await build_archives_task_payload(flat, _fake_inventory()) - form_payload = _normalize(form_task.model_dump()) - - assert form_payload == spec_payload diff --git a/tests/app/sep/apps/archives/test_routes.py b/tests/app/sep/apps/archives/test_routes.py deleted file mode 100644 index 607591a993..0000000000 --- a/tests/app/sep/apps/archives/test_routes.py +++ /dev/null @@ -1,866 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <https://www.gnu.org/licenses/>. - -"""Define tests for the app.sep.apps.archives.routes module.""" - -import re -from datetime import datetime, timedelta, UTC -from unittest.mock import AsyncMock - -import pytest -import yaml -from fastapi import status - -from app.core.pagination import MAX_PAGINATION_LIMIT -from app.core.requests import RemoteAPI -from app.inventory.models import ServiceTypeEnum -from app.sep.apps.archives.constants import SwapDropEnum -from app.sep.apps.archives.deps import ( - build_archives_task_payload, - get_archives_index_context, - get_archives_task, -) -from app.sep.connectivity import ( - clear_connectivity_caches, - get_latest_connectivity_result, -) -from app.sep.deps import get_inventory_api -from app.sep.main import sep_app -from app.tasks.models import ( - TaskBackendEnum, - TaskHistoryStatusEnum, -) -from tests.app.factories import GeneratedTaskFactory, TaskFactory - - -@pytest.fixture -def mock_inventory_api_dep(mock_remote_api: RemoteAPI) -> AsyncMock: - """Mock the InventoryAPI dependency.""" - mock = AsyncMock(spec=RemoteAPI) - sep_app.dependency_overrides[get_inventory_api] = lambda: mock - yield mock - sep_app.dependency_overrides = {} - - -@pytest.fixture -def created_archives() -> dict: - """Return flat legacy-form data for the Jinja create route (Purge Only).""" - return { - "alias": "purge_only", - "hostname": "source_db", - "service_id": 1, - "source_db_id": 10, - "source_table_id": 20, - "swap_drop": SwapDropEnum.PURGE_ONLY.value, - "where": "id > 1", - "delete_data": 1, - } - - -@pytest.fixture -def _mock_archives_task_payload(generated_task): - """Mock the TaskDep dependency.""" - sep_app.dependency_overrides[build_archives_task_payload] = lambda: generated_task - yield - sep_app.dependency_overrides = {} - - -@pytest.fixture -def created_task(): - """Return a fake created Task instance.""" - return TaskFactory.build(owner="ARCHIVER") - - -@pytest.fixture -def _mock_get_archives_task_dep(created_task): - """Mock the TaskDep dependency.""" - sep_app.dependency_overrides[get_archives_task] = lambda: created_task - yield - sep_app.dependency_overrides = {} - - -@pytest.fixture -def _mock_get_archives_index_context_dep(): - """Mock the get_archives_index_context dependency with default user context.""" - sep_app.dependency_overrides[get_archives_index_context] = lambda: { - "user": "default_user", - "connectivity_check_default": True, - # An executor host is required for the index to render the create form, - # which exercises the dest_file markup guard asserted below. - "executor_hosts": [{"value": "host1", "label": "host1"}], - "services": [], - } - yield - sep_app.dependency_overrides = {} - - -@pytest.mark.usefixtures("_mock_get_archives_index_context_dep") -def test_archives_index( - test_client, -): - """Test listing archives tasks.""" - response = test_client.get("/archives/") - assert response.status_code == status.HTTP_200_OK - assert response.headers["content-type"] == "text/html; charset=utf-8" - assert response.headers.get("deprecation") == "true" - # Render guard only: name="dest_file" is always in the server HTML; the - # inline JS strips it at runtime, which this does not exercise. - assert 'name="dest_file"' in response.text - - -@pytest.mark.usefixtures("_mock_archives_task_payload") -def test_archives_create( - test_client, - mock_task_api_dep, - created_archives, - generated_task, -): - """Test creating a new archives task.""" - response = test_client.post( - "/archives/", data=created_archives, follow_redirects=False - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert ( - response.headers["location"] - == f"{test_client.base_url}/archives/{generated_task.name}" - ) - mock_task_api_dep.post.assert_any_await( - "/", - json=generated_task.model_dump(), - ) - - -def test_archives_create_full_form_dependency_chain_without_payload_override( - test_client, - mock_task_api_dep, - mock_inventory_api_dep, - created_service, - created_schema, - created_table, -): - """Test POST /archives/ route without overriding build_archives_task_payload.""" - flat = { - "alias": "arch_full_chain", - "hostname": "source_db", - "service_id": created_service.id, - "source_db_id": created_schema.id, - "source_table_id": created_table.id, - "swap_drop": SwapDropEnum.PURGE_ONLY.value, - "where": "id > 1", - "delete_data": 1, - } - mock_inventory_api_dep.get = AsyncMock( - side_effect=[ - created_service.model_dump(), - created_schema.model_dump(), - created_table.model_dump(), - ] - ) - mock_task_api_dep.post.return_value = AsyncMock() - - response = test_client.post("/archives/", data=flat, follow_redirects=False) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"].endswith("/archives/arch_full_chain") - mock_task_api_dep.post.assert_awaited_once() - assert mock_task_api_dep.post.await_args.args[0] == "/" - posted = mock_task_api_dep.post.await_args.kwargs["json"] - assert posted["name"] == "arch_full_chain" - assert posted["owner"] == "ARCHIVER" - assert posted["data"]["meta"]["_service_name"] == created_service.name - - -class TestArchivesUpdateFormChain: - """Exercise the real ``Form()`` dependency chain on POST /archives/{task_name}/update.""" - - def test_full_form_dependency_chain_without_payload_override( - self, - test_client, - mock_task_api_dep, - mock_inventory_api_dep, - created_service, - created_schema, - created_table, - ): - """Forward the submitted body to ``tasks_api.put`` through the real form chain.""" - task_name = "arch_update_full_chain" - flat = { - "alias": task_name, - "hostname": "source_db", - "service_id": created_service.id, - "source_db_id": created_schema.id, - "source_table_id": created_table.id, - "swap_drop": SwapDropEnum.PURGE_ONLY.value, - "where": "id > 1", - "delete_data": 1, - } - mock_inventory_api_dep.get = AsyncMock( - side_effect=[ - created_service.model_dump(), - created_schema.model_dump(), - created_table.model_dump(), - ] - ) - mock_task_api_dep.put.return_value = AsyncMock() - - response = test_client.post( - f"/archives/{task_name}/update", data=flat, follow_redirects=False - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"].endswith(f"/archives/{task_name}") - mock_task_api_dep.put.assert_awaited_once() - assert mock_task_api_dep.put.await_args.args[0] == f"/{task_name}" - put_payload = mock_task_api_dep.put.await_args.kwargs["json"] - assert put_payload["name"] == task_name - assert put_payload["owner"] == "ARCHIVER" - assert put_payload["data"]["meta"]["_service_name"] == created_service.name - # Submitted ``where`` clause must survive the real form chain into the payload. - purge_config = yaml.safe_load(put_payload["data"]["meta"]["config"]) - assert purge_config["PURGE_LIST"][0]["WHERE"] == "id > 1" - - def test_forwards_path_name_but_redirects_to_form_name( - self, - test_client, - mock_task_api_dep, - mock_inventory_api_dep, - created_service, - created_schema, - created_table, - ): - """Forward the path ``task_name`` to put while the form alias drives the redirect.""" - path_name = "arch_url_path" - form_name = "arch_form_name" - flat = { - "alias": form_name, - "hostname": "source_db", - "service_id": created_service.id, - "source_db_id": created_schema.id, - "source_table_id": created_table.id, - "swap_drop": SwapDropEnum.PURGE_ONLY.value, - "where": "id > 1", - "delete_data": 1, - } - mock_inventory_api_dep.get = AsyncMock( - side_effect=[ - created_service.model_dump(), - created_schema.model_dump(), - created_table.model_dump(), - ] - ) - mock_task_api_dep.put.return_value = AsyncMock() - - response = test_client.post( - f"/archives/{path_name}/update", data=flat, follow_redirects=False - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert mock_task_api_dep.put.await_args.args[0] == f"/{path_name}" - assert mock_task_api_dep.put.await_args.kwargs["json"]["name"] == form_name - assert response.headers["location"].endswith(f"/archives/{form_name}") - - def test_invalid_form_flashes_and_redirects_without_forwarding( - self, - test_client, - mock_task_api_dep, - ): - """Flash and redirect (never forward) when the update form fails validation.""" - # ``where`` is required for a Purge Only run, so omitting it fails - # ``ArchivesCreate`` validation before any inventory lookup or forward. - data = { - "alias": "arch_invalid", - "hostname": "source_db", - "service_id": 1, - "source_db_id": 10, - "source_table_id": 20, - "swap_drop": SwapDropEnum.PURGE_ONLY.value, - "delete_data": 1, - } - - response = test_client.post( - "/archives/arch_invalid/update", data=data, follow_redirects=False - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"] == "/" - assert "messages=" in response.headers.get("set-cookie", "") - mock_task_api_dep.put.assert_not_awaited() - - -def test_archives_create_accepts_empty_dest_port( - test_client, - mock_task_api_dep, - mock_inventory_api_dep, - created_service, - created_schema, - created_table, -): - """POST /archives/ coerces empty-string optional ints to None. - - Goes through the real form-parsing chain (no ``build_archives_task_payload`` - override) so the ``EmptyStrToNone`` ``BeforeValidator`` is the only thing - stopping the previous 422 response. - """ - payload = { - "alias": "arch_empty_port", - "hostname": "source_db", - "service_id": created_service.id, - "source_db_id": created_schema.id, - "source_table_id": created_table.id, - "swap_drop": SwapDropEnum.PURGE_ONLY.value, - "where": "id > 1", - "delete_data": 1, - "dest_port": "", - "limit": "", - } - mock_inventory_api_dep.get = AsyncMock( - side_effect=[ - created_service.model_dump(), - created_schema.model_dump(), - created_table.model_dump(), - ] - ) - mock_task_api_dep.post.return_value = AsyncMock() - - response = test_client.post("/archives/", data=payload, follow_redirects=False) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"].endswith("/archives/arch_empty_port") - mock_task_api_dep.post.assert_awaited_once() - - -def test_archives_update_accepts_empty_dest_port( - test_client, - mock_task_api_dep, - mock_inventory_api_dep, - created_service, - created_schema, - created_table, -): - """POST /archives/{task_name}/update coerces empty-string optional ints to None. - - Same real-form-binding pattern as the create test — the update route shares - the ``ArchivesGeneratedTask`` dep, so the empty-string fix has to cover it too. - """ - payload = { - "alias": "arch_update_empty_port", - "hostname": "source_db", - "service_id": created_service.id, - "source_db_id": created_schema.id, - "source_table_id": created_table.id, - "swap_drop": SwapDropEnum.PURGE_ONLY.value, - "where": "id > 1", - "delete_data": 1, - "dest_port": "", - "limit": "", - } - mock_inventory_api_dep.get = AsyncMock( - side_effect=[ - created_service.model_dump(), - created_schema.model_dump(), - created_table.model_dump(), - ] - ) - mock_task_api_dep.put.return_value = AsyncMock() - - response = test_client.post( - "/archives/arch_update_empty_port/update", - data=payload, - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"].endswith("/archives/arch_update_empty_port") - mock_task_api_dep.put.assert_awaited_once() - - -def test_archives_create_same_as_source_with_manual_dest_schema( - test_client, - mock_task_api_dep, - mock_inventory_api_dep, - created_service, - created_schema, - created_table, -): - """POST /archives/ with same-as-source host and a manual destination schema. - - With no ``dest_service_id`` and no ``dest_host`` but a typed ``dest_db_name``, - the posted purge config must carry ``DEST_DB`` (archive to a different schema - on the same host) and omit ``DEST_HOST`` entirely. Goes through the real - form-binding chain (no ``build_archives_task_payload`` override). - """ - flat = { - "alias": "arch_same_host_manual_schema", - "hostname": "source_db", - "service_id": created_service.id, - "source_db_id": created_schema.id, - "source_table_id": created_table.id, - "swap_drop": SwapDropEnum.PURGE_ONLY.value, - "where": "id > 1", - "dest_table_name": "archived_tbl", - "dest_db_name": "archive_db", - } - mock_inventory_api_dep.get = AsyncMock( - side_effect=[ - created_service.model_dump(), - created_schema.model_dump(), - created_table.model_dump(), - ] - ) - mock_task_api_dep.post.return_value = AsyncMock() - - response = test_client.post("/archives/", data=flat, follow_redirects=False) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"].endswith( - "/archives/arch_same_host_manual_schema" - ) - mock_task_api_dep.post.assert_awaited_once() - posted = mock_task_api_dep.post.await_args.kwargs["json"] - purge_config = yaml.safe_load(posted["data"]["meta"]["config"]) - purge_item = purge_config["PURGE_LIST"][0] - assert purge_item["DEST_DB"] == "archive_db" - assert "DEST_HOST" not in purge_item - - -def test_archives_update_same_as_source_with_manual_dest_schema( - test_client, - mock_task_api_dep, - mock_inventory_api_dep, - created_service, - created_schema, - created_table, -): - """POST /archives/{task_name}/update with same-as-source + manual dest schema. - - The update route shares the form-binding chain, so the same same-host - manual-schema path has to reach ``DEST_DB`` (and omit ``DEST_HOST``) on the - PUT payload too. - """ - flat = { - "alias": "arch_update_same_host_manual_schema", - "hostname": "source_db", - "service_id": created_service.id, - "source_db_id": created_schema.id, - "source_table_id": created_table.id, - "swap_drop": SwapDropEnum.PURGE_ONLY.value, - "where": "id > 1", - "dest_table_name": "archived_tbl", - "dest_db_name": "archive_db", - } - mock_inventory_api_dep.get = AsyncMock( - side_effect=[ - created_service.model_dump(), - created_schema.model_dump(), - created_table.model_dump(), - ] - ) - mock_task_api_dep.put.return_value = AsyncMock() - - response = test_client.post( - "/archives/arch_update_same_host_manual_schema/update", - data=flat, - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"].endswith( - "/archives/arch_update_same_host_manual_schema" - ) - mock_task_api_dep.put.assert_awaited_once() - put_payload = mock_task_api_dep.put.await_args.kwargs["json"] - purge_config = yaml.safe_load(put_payload["data"]["meta"]["config"]) - purge_item = purge_config["PURGE_LIST"][0] - assert purge_item["DEST_DB"] == "archive_db" - assert "DEST_HOST" not in purge_item - - -def test_archives_update_rejects_non_purge_only_swap_drop( - test_client, - mock_task_api_dep, - mock_inventory_api_dep, -): - """Resubmitting a legacy SWAP_DROP task via the update route is rejected. - - Existing SWAP_DROP / SWAP_ARCHIVE_DROP tasks load read-only, but saving an - edit re-validates through ``ArchivesCreate``; only Purge Only is accepted, - so a crafted ``swap_drop=1`` form fails validation (422) regardless of the - disabled UI. - """ - # Posted as a raw form dict because ArchivesCreate refuses to construct a - # non-Purge-Only payload in the first place. - payload = { - "alias": "legacy_swap_drop", - "hostname": "source_db", - "service_id": 1, - "source_db_id": 10, - "source_table_id": 20, - "swap_drop": SwapDropEnum.SWAP_DROP.value, - } - - # HTML form routes surface validation failures as a flash + redirect back to - # the submitting page (the Referer), so set one and assert we return to it - # rather than to the detail page. - referer = "http://testserver/archives/legacy_swap_drop/edit" - response = test_client.post( - "/archives/legacy_swap_drop/update", - data=payload, - headers={"referer": referer}, - follow_redirects=False, - ) - - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"] == referer - # The task is never updated when validation rejects the payload. - mock_task_api_dep.put.assert_not_awaited() - - -def test_archives_create_skips_connectivity_check_when_opted_out( - test_client, mock_task_api_dep, created_archives -): - """POST /archives/ skips the connectivity check when the checkbox is unchecked.""" - clear_connectivity_caches() - - fake_task_write = GeneratedTaskFactory.build( - name="fake_task", - backend=TaskBackendEnum.PROXY, - owner="ARCHIVER", - data={ - "task": "fake-task", - "meta": { - "target": "node1", - "_connectivity_host": "10.0.0.1", - "_connectivity_port": 3306, - "_connectivity_service_type": ServiceTypeEnum.MYSQL.value, - }, - "payload": "", - }, - ) - - sep_app.dependency_overrides[build_archives_task_payload] = lambda: fake_task_write - - response = test_client.post( - "/archives/", data=created_archives, follow_redirects=False - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert ( - response.headers["location"] - == f"{test_client.base_url}/archives/{fake_task_write.name}" - ) - - assert mock_task_api_dep.post.call_count == 1 - call = mock_task_api_dep.post.call_args_list[0] - assert call.args[0] == "/" - assert call.kwargs["json"] == fake_task_write.model_dump() - assert get_latest_connectivity_result("node1", "mysql") is None - - clear_connectivity_caches() - sep_app.dependency_overrides = {} - - -@pytest.mark.parametrize( - ("disable_bulk_insert", "checkbox_checked"), - [ - (None, False), - (1, True), - ], - ids=["bulk_insert_default", "bulk_insert_checked"], -) -@pytest.mark.usefixtures("_mock_get_archives_task_dep", "mock_get_username_mapping") -def test_archives_detail( - test_client, - created_task, - mock_task_api_dep, - mock_inventory_api_dep, - disable_bulk_insert, - checkbox_checked, -): - """Test the archives detail page, including the DISABLE_BULK_INSERT checkbox state. - - The ``DISABLE_BULK_INSERT`` flag in the ``PURGE_LIST`` meta config drives whether the - ``disable_bulk_insert`` checkbox renders as ``checked``. - """ - purge_entry = { - "ALIAS": "test_archiver_task", - "SOURCE_DB": "mock_source_db", - "SOURCE_TABLE": "mock_source_table", - "SWAP_DROP": 1, - } - if disable_bulk_insert is not None: - purge_entry["DISABLE_BULK_INSERT"] = disable_bulk_insert - mock_meta_config = yaml.dump( - { - "ALL": { - "SOURCE_HOST": "127.0.0.1", - "SOURCE_PORT": 3306, - }, - "PURGE_LIST": [purge_entry], - } - ) - - mock_data = { - "meta": { - "config": mock_meta_config, - "target": "mock_target", - }, - "hostname": "mock_nomad_host_name", - } - created_task.data = mock_data - mock_inventory_api_dep.get.return_value = { - "items": [], - "total": 0, - "offset": 0, - "limit": 50, - } - mock_task_api_dep.get.side_effect = [ - {"127.0.0.1": "localhost"}, # for /hosts/ (dependency) - { - "items": [], - "total": 0, - "offset": 0, - "limit": 50, - }, # for /{task.name}/history/ - { - "items": [], - "total": 0, - "offset": 0, - "limit": 50, - }, # for running tasks at /{task.name}/history/ - [], # for /stats/{task.name} - {"items": [], "total": 0, "offset": 0, "limit": 50}, # chainable_tasks - ] - response = test_client.get(f"/archives/{created_task.name}") - assert response.status_code == status.HTTP_200_OK - assert created_task.name in response.text - assert f"/archives/{created_task.name}/delete" in response.text - assert f"/tasks/{created_task.name}/delete" not in response.text - assert 'name="disable_bulk_insert"' in response.text - # Render guard only (see test_archives_index); does not exercise the - # inline-JS gating. - assert 'name="dest_file"' in response.text - checkbox_is_checked = bool( - re.search(r'name="disable_bulk_insert"[^>]*checked', response.text) - ) - assert checkbox_is_checked is checkbox_checked - mock_task_api_dep.get.assert_any_await(f"/{created_task.name}/history/") - mock_task_api_dep.get.assert_any_await( - f"/{created_task.name}/history/", - params={"status": TaskHistoryStatusEnum.RUNNING}, - ) - mock_task_api_dep.get.assert_any_await(f"/stats/{created_task.name}") - mock_task_api_dep.get.assert_any_await("/hosts/") - mock_inventory_api_dep.get.assert_any_await( - "/services/", - params={ - "service_type": ServiceTypeEnum.MYSQL, - "offset": 0, - "limit": MAX_PAGINATION_LIMIT, - }, - ) - - -@pytest.mark.usefixtures("_mock_get_archives_task_dep", "mock_get_username_mapping") -def test_archives_detail_with_remote_destination( - test_client, created_task, mock_task_api_dep, mock_inventory_api_dep -): - """Test archives detail page renders DEST_HOST, DEST_PORT, DEST_DB correctly.""" - mock_meta_config = yaml.dump( - { - "ALL": { - "SOURCE_HOST": "127.0.0.1", - "SOURCE_PORT": 3306, - }, - "PURGE_LIST": [ - { - "ALIAS": "test_remote_archiver", - "SOURCE_DB": "source_db", - "SOURCE_TABLE": "source_table", - "DEST_TABLE": "dest_table", - "DEST_HOST": "remote.host", - "DEST_PORT": 3307, - "DEST_DB": "remote_db", - "SWAP_DROP": 1, - } - ], - } - ) - - mock_data = { - "meta": { - "config": mock_meta_config, - "target": "mock_target", - }, - "hostname": "mock_nomad_host_name", - } - created_task.data = mock_data - mock_inventory_api_dep.get.return_value = { - "items": [], - "total": 0, - "offset": 0, - "limit": 50, - } - mock_task_api_dep.get.side_effect = [ - {"127.0.0.1": "localhost"}, - {"items": [], "total": 0, "offset": 0, "limit": 50}, - {"items": [], "total": 0, "offset": 0, "limit": 50}, - [], - {"items": [], "total": 0, "offset": 0, "limit": 50}, - ] - response = test_client.get(f"/archives/{created_task.name}") - assert response.status_code == status.HTTP_200_OK - # Destination table should be qualified with DEST_DB not SOURCE_DB - assert "dest_table" in response.text - # Destination host should be displayed - assert "remote.host" in response.text - # Destination port should be displayed - assert "3307" in response.text - # Remote database should be displayed - assert "remote_db" in response.text - - -def test_archives_create_form_renders_dest_schema_name_field(test_client): - """GET /archives/ renders the standalone manual destination-schema field. - - The ``dest_db_name`` input lives in its own ``#dest_schema_name_field`` - container (decoupled from the manual-host block) so it can be shown when the - destination host is the same as the source. - """ - sep_app.dependency_overrides[get_archives_index_context] = lambda: { - "user": "default_user", - "connectivity_check_default": True, - "csrf_token": "test-csrf", - "services": [], - "executor_hosts": [{"value": "host1", "label": "Host 1"}], - "tasks": [], - "history_tasks": [], - } - try: - response = test_client.get("/archives/") - finally: - sep_app.dependency_overrides.pop(get_archives_index_context, None) - - assert response.status_code == status.HTTP_200_OK - assert 'id="dest_schema_name_field"' in response.text - assert 'name="dest_db_name"' in response.text - - -def test_archives_create_form_renders_delete_data_label(test_client): - """Verify GET /archives/ renders the delete_data toggle with its disambiguated label. - - The legacy Jinja create form is still reachable via a direct ``/archives`` - URL (deprecated route), so its misleading ``DELETE_DATA`` label must read as - "Delete Without Archiving" to match the React form and avoid the data-loss - confusion. - """ - sep_app.dependency_overrides[get_archives_index_context] = lambda: { - "user": "default_user", - "connectivity_check_default": True, - "csrf_token": "test-csrf", - "services": [], - "executor_hosts": [{"value": "host1", "label": "Host 1"}], - "tasks": [], - "history_tasks": [], - } - try: - response = test_client.get("/archives/") - finally: - sep_app.dependency_overrides.pop(get_archives_index_context, None) - - assert response.status_code == status.HTTP_200_OK - assert "Delete Without Archiving" in response.text - assert "DELETE_DATA" not in response.text - - -@pytest.mark.usefixtures("_mock_get_archives_task_dep", "mock_get_username_mapping") -def test_archives_detail_same_as_source_rehydrates_dest_schema( - test_client, created_task, mock_task_api_dep, mock_inventory_api_dep -): - """Edit form rehydrates a same-as-source task's manual destination schema. - - A task saved with a destination schema but no destination host (``DEST_DB`` - set, ``DEST_HOST`` absent) must render the standalone - ``#dest_schema_name_field`` container with the saved schema pre-filled. - """ - mock_meta_config = yaml.dump( - { - "ALL": { - "SOURCE_HOST": "127.0.0.1", - "SOURCE_PORT": 3306, - }, - "PURGE_LIST": [ - { - "ALIAS": "test_same_host_manual_schema", - "SOURCE_DB": "source_db", - "SOURCE_TABLE": "source_table", - "DEST_TABLE": "dest_table", - "DEST_DB": "archive_db", - "SWAP_DROP": 1, - } - ], - } - ) - - mock_data = { - "meta": { - "config": mock_meta_config, - "target": "mock_target", - }, - "hostname": "mock_nomad_host_name", - } - created_task.data = mock_data - mock_inventory_api_dep.get.return_value = AsyncMock() - mock_task_api_dep.get.side_effect = [ - {"127.0.0.1": "localhost"}, - {"items": [], "total": 0, "offset": 0, "limit": 50}, - {"items": [], "total": 0, "offset": 0, "limit": 50}, - [], - {"items": [], "total": 0, "offset": 0, "limit": 50}, - ] - response = test_client.get(f"/archives/{created_task.name}") - assert response.status_code == status.HTTP_200_OK - # The manual destination-schema field is its own container... - assert 'id="dest_schema_name_field"' in response.text - # ...and the saved schema name is server-rendered into that input. - assert re.search(r'id="dest_db_name"[^>]*value="archive_db"', response.text) - - -@pytest.mark.usefixtures( - "_mock_get_archives_task_dep", "_mock_check_for_conflicted_running_tasks" -) -def test_archives_execute( - test_client, - created_task, - mock_task_api_dep, -): - """Test executing an archives task.""" - mock_task_api_dep.post.return_value = AsyncMock() - eta = datetime.now(tz=UTC) + timedelta(days=1) - response = test_client.post( - f"/archives/{created_task.name}", data={"eta": str(eta)}, follow_redirects=False - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert ( - response.headers["location"] - == f"{test_client.base_url}/archives/{created_task.name}" - ) - - -@pytest.mark.usefixtures("_mock_get_archives_task_dep") -def test_archives_delete( - test_client, - created_task, - mock_task_api_dep, -): - """Test deleting an archives task.""" - mock_task_api_dep.delete.return_value = AsyncMock() - - response = test_client.post( - f"/archives/{created_task.name}/delete", follow_redirects=False - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"] == "/archives" - mock_task_api_dep.delete.assert_awaited_once_with(f"/{created_task.name}") diff --git a/tests/app/sep/apps/atw/conftest.py b/tests/app/sep/apps/atw/conftest.py index 9f4f5338d7..222ec6c35f 100644 --- a/tests/app/sep/apps/atw/conftest.py +++ b/tests/app/sep/apps/atw/conftest.py @@ -28,11 +28,9 @@ from app.core.utils import json_serializer from app.sep.bundle_upload.plan import DeliveryPlan from app.sep.deps import ( - get_api_authenticated_user, get_current_user, get_session, require_bearer_for_unsafe_methods, - validate_csrf, ) from app.sep.main import sep_app @@ -122,10 +120,8 @@ async def async_api_client( asserting cascade deletes): the request and the DB check then run on the same event loop and session, which a sync ``TestClient`` cannot offer. """ - sep_app.dependency_overrides[validate_csrf] = lambda: True sep_app.dependency_overrides[require_bearer_for_unsafe_methods] = lambda: None sep_app.dependency_overrides[get_current_user] = lambda: regular_user - sep_app.dependency_overrides[get_api_authenticated_user] = lambda: regular_user sep_app.dependency_overrides[get_session] = lambda: session transport = ASGITransport(app=sep_app) client = AsyncClient( diff --git a/tests/app/sep/apps/atw/test_batch_api.py b/tests/app/sep/apps/atw/test_batch_api.py index 9552e563a4..0cedb1feee 100644 --- a/tests/app/sep/apps/atw/test_batch_api.py +++ b/tests/app/sep/apps/atw/test_batch_api.py @@ -44,12 +44,10 @@ from app.sep.apps.atw.crud import AtwIncidentExecutionManager, AtwIncidentManager from app.sep.apps.atw.models import AtwIncident, AtwIncidentExecution from app.sep.deps import ( - get_api_authenticated_user, get_current_user, get_session, get_tasks_api, require_bearer_for_unsafe_methods, - validate_csrf, ) from app.sep.main import sep_app from app.sep.snippets.config import snippets_settings, SnippetSudoOption @@ -1505,10 +1503,8 @@ async def _flaky_save( mocker.patch.object(AtwIncidentExecutionManager, "save", new=_flaky_save) - sep_app.dependency_overrides[validate_csrf] = lambda: True sep_app.dependency_overrides[require_bearer_for_unsafe_methods] = lambda: None sep_app.dependency_overrides[get_current_user] = lambda: regular_user - sep_app.dependency_overrides[get_api_authenticated_user] = lambda: regular_user sep_app.dependency_overrides[get_session] = lambda: postgres_session client = AsyncClient( transport=ASGITransport(app=sep_app), base_url="http://test" diff --git a/tests/app/sep/apps/backup_mongo/restore/test_deps.py b/tests/app/sep/apps/backup_mongo/restore/test_deps.py index 8d985861d1..a0cbd7cde6 100644 --- a/tests/app/sep/apps/backup_mongo/restore/test_deps.py +++ b/tests/app/sep/apps/backup_mongo/restore/test_deps.py @@ -27,13 +27,7 @@ from app.sep.apps.backup_mongo.restore.deps import ( _backup_type_from_parent, _resolve_service_name, - build_pbm_force_resync_task_payload, - build_pbm_list_task_payload, - build_restore_config_task_payload, build_restore_task_group, - build_restore_task_group_from_body, - build_restore_task_payload, - build_restore_tasks, build_restore_update_form_from_body, ) from app.sep.apps.backup_mongo.restore.models import RestoreCreate, RestoreTaskWrite @@ -45,12 +39,7 @@ EXPECTED_PHYSICAL_RESTORE_TUPLE_LEN = 4 DispatchFn = Callable[[RestoreCreate, object], Awaitable[TaskWrite]] -DISPATCH_FUNCTIONS: list[DispatchFn] = [ - build_restore_config_task_payload, - build_restore_task_payload, - build_pbm_list_task_payload, - build_pbm_force_resync_task_payload, -] +DISPATCH_FUNCTIONS: list[DispatchFn] = [] def _restore_parent_task(*, config: str) -> Task: @@ -173,61 +162,6 @@ async def test_resolve_service_name_returns_service_name_when_service_id_present ) -@pytest.mark.asyncio -async def test_build_restore_tasks_threads_inventory_api_to_all_dispatch_fns( - mocker, - mock_remote_api, - mongo_service: CreatedService, -): - """build_restore_tasks returns a 4-tuple with _service_name set on every sub-task for physical restores. - - Also asserts the inventory lookup is performed only once across all four - sub-tasks — the four builders share the resolved name rather than each - re-querying the Inventory API for the same ``service_id``. - """ - get_created_entity = mocker.patch( - "app.sep.apps.backup_mongo.restore.deps.get_created_entity", - return_value=mongo_service, - ) - physical_form = RestoreCreate( - hostname="mongo-restore-host", - task_name="mongo-restore-task", - service_id=str(mongo_service.id), - backup_type=BackupType.PBM_PHYSICAL, - backup_source="2026-04-29T10:00:00", - ) - - tasks = await build_restore_tasks(physical_form, mock_remote_api) - - assert len(tasks) == EXPECTED_PHYSICAL_RESTORE_TUPLE_LEN - assert all(task is not None for task in tasks) - for task in tasks: - assert task.data["meta"]["_service_name"] == mongo_service.name - get_created_entity.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_build_restore_tasks_logical_omits_force_resync( - mocker, - mock_remote_api, - restore_create: RestoreCreate, - mongo_service: CreatedService, -): - """Logical restores skip the force-resync sub-task (last tuple element is None).""" - mocker.patch( - "app.sep.apps.backup_mongo.restore.deps.get_created_entity", - return_value=mongo_service, - ) - - tasks = await build_restore_tasks(restore_create, mock_remote_api) - - assert len(tasks) == EXPECTED_PHYSICAL_RESTORE_TUPLE_LEN - *populated_tasks, force_resync = tasks - assert force_resync is None - for task in populated_tasks: - assert task.data["meta"]["_service_name"] == mongo_service.name - - @pytest.mark.asyncio async def test_restore_task_group_yaml_golden_strings( mocker, @@ -284,31 +218,6 @@ async def test_restore_task_group_yaml_golden_strings( ) -@pytest.mark.asyncio -async def test_build_restore_task_group_from_body_matches_form_path( - mocker, - mock_remote_api, - restore_create: RestoreCreate, - restore_task_write: RestoreTaskWrite, - mongo_service: CreatedService, -) -> None: - """JSON body composer produces the same payloads as the form path.""" - mocker.patch( - "app.sep.apps.backup_mongo.restore.deps.get_created_entity", - return_value=mongo_service, - ) - - from_body = await build_restore_task_group_from_body( - restore_task_write, - mock_remote_api, - ) - from_form = await build_restore_task_group(restore_create, mock_remote_api) - - assert from_body == from_form - assert from_body.config_task.name == restore_create.task_name - assert from_body.force_resync_task is None - - def test_build_restore_update_form_from_body_pins_parent_identity( restore_task_write: RestoreTaskWrite, ) -> None: diff --git a/tests/app/sep/apps/backup_mongo/restore/test_routes.py b/tests/app/sep/apps/backup_mongo/restore/test_routes.py deleted file mode 100644 index 90b709d096..0000000000 --- a/tests/app/sep/apps/backup_mongo/restore/test_routes.py +++ /dev/null @@ -1,147 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <https://www.gnu.org/licenses/>. - -"""Define tests for the app.sep.apps.backup_mongo.restore.routes module.""" - -from typing import Any -from unittest.mock import AsyncMock - -import pytest -import yaml -from fastapi import status - -from app.sep.apps.backup_mongo.restore.deps import get_restores_index_context -from app.sep.apps.backup_mongo.restore.models import RestoreCreate -from app.sep.inventory import CreatedService -from app.sep.main import sep_app -from app.tasks.models import TaskBackendEnum -from tests.app.factories import TaskFactory - -EXPECTED_LOGICAL_RESTORE_POSTS = 3 - - -def test_pbm_restores_create_full_form_dependency_chain_without_payload_override( - test_client, - mock_task_api_dep, - mock_inventory_api_dep, - restore_create: RestoreCreate, - mongo_service: CreatedService, -): - """POST /backup_mongo/restores/ resolves the real dep graph and tags every sub-task with _service_name.""" - mock_inventory_api_dep.get = AsyncMock(return_value=mongo_service.model_dump()) - mock_task_api_dep.post.return_value = AsyncMock() - - response = test_client.post( - "/backup_mongo/restores/", - data=restore_create.model_dump(exclude_none=True), - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - # Logical restores POST 3 sub-tasks (config, restore, pbm-list); physical adds force-resync. - assert mock_task_api_dep.post.await_count == EXPECTED_LOGICAL_RESTORE_POSTS - for call in mock_task_api_dep.post.await_args_list: - posted = call.kwargs["json"] - assert posted["data"]["meta"]["_service_name"] == mongo_service.name - - -def test_pbm_restores_update_full_form_dependency_chain_without_payload_override( - test_client, - mock_task_api_dep, - mock_inventory_api_dep, - restore_create: RestoreCreate, - mongo_service: CreatedService, -): - """POST /backup_mongo/restores/{task_name}/update resolves the real dep graph for updates too.""" - mock_inventory_api_dep.get = AsyncMock(return_value=mongo_service.model_dump()) - mock_task_api_dep.put.return_value = AsyncMock() - - response = test_client.post( - f"/backup_mongo/restores/{restore_create.task_name}/update", - data=restore_create.model_dump(exclude_none=True), - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - mock_task_api_dep.put.assert_awaited_once() - posted = mock_task_api_dep.put.await_args.kwargs["json"] - assert posted["data"]["meta"]["_service_name"] == mongo_service.name - - -def test_pbm_restores_detail_tolerates_missing_backup_type( - test_client, - mock_task_api_dep, - mock_inventory_api_dep, -): - """GET detail returns 200 when config has no backupType.""" - task = TaskFactory.build( - name="mongo-restore-task", - owner="RESTORE_MONGO", - backend=TaskBackendEnum.PROXY, - ).model_dump(mode="json") - task["data"] = { - "task": "run-python", - "meta": { - "target": "mongo-restore-host", - "config": yaml.dump( - { - "backupSource": "2026-04-29T10:00:00", - "restore": {}, - }, - default_flow_style=False, - ), - "requirements": "packaging\nPyYAML", - }, - "payload": "file:///plugins/backup_mongo/restore/restore_config_payload", - } - - async def _mock_get(path: str, **kwargs: Any): - if path == "/mongo-restore-task": - return task - if path == "/": - return {"items": [task], "total": 1} - if path == "/hosts/": - return {} - if path == "/services/": - return {"items": []} - if path.startswith("/stats/"): - return {} - if path.endswith("/history/"): - return {"items": []} - raise AssertionError(f"Unexpected path: {path!r}, kwargs={kwargs!r}") - - mock_task_api_dep.get = AsyncMock(side_effect=_mock_get) - mock_inventory_api_dep.get = AsyncMock(return_value={"items": []}) - - response = test_client.get("/backup_mongo/restores/mongo-restore-task") - - assert response.status_code == status.HTTP_200_OK - - -@pytest.fixture -def _mock_get_restores_index_context_dep(): - """Mock the get_restores_index_context dependency with a stub context.""" - sep_app.dependency_overrides[get_restores_index_context] = lambda: { - "user": "default_user", - } - yield - sep_app.dependency_overrides = {} - - -@pytest.mark.usefixtures("_mock_get_restores_index_context_dep") -def test_pbm_restores_index(test_client): - """GET /backup_mongo/restores/ renders the index via the relocated RestoresIndexContext alias.""" - response = test_client.get("/backup_mongo/restores/") - - assert response.status_code == status.HTTP_200_OK - assert response.headers["content-type"] == "text/html; charset=utf-8" diff --git a/tests/app/sep/apps/backup_mongo/test_routes.py b/tests/app/sep/apps/backup_mongo/test_routes.py deleted file mode 100644 index 1f76fc2ad8..0000000000 --- a/tests/app/sep/apps/backup_mongo/test_routes.py +++ /dev/null @@ -1,204 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <https://www.gnu.org/licenses/>. - -"""Define tests for the app.sep.apps.backup_mongo.routes module.""" - -from typing import Any -from unittest.mock import AsyncMock - -import yaml -from fastapi import status - -from app.core.exceptions import HTTPNotFoundException -from app.sep.apps.backup_mongo.models import BackupCreate, BackupType -from app.sep.inventory import CreatedService -from app.tasks.models import TaskBackendEnum -from tests.app.factories import TaskFactory - -EXPECTED_PBM_TASK_POSTS = 5 - - -def _parent_backup_task() -> dict[str, Any]: - """Build a parent ``pbm_config`` task payload for legacy detail tests.""" - task = TaskFactory.build( - name="mongo-backup-task", - owner="BACKUP_MONGO", - backend=TaskBackendEnum.PROXY, - ).model_dump(mode="json") - task["data"] = { - "task": "run-python", - "backup_type": BackupType.PBM_CONFIG.value, - "meta": { - "target": "mongo-host", - "config": yaml.dump( - {"storage": {"type": "filesystem", "filesystem": {"path": "/tmp/pbm"}}}, - default_flow_style=False, - ), - }, - "payload": "file:///plugins/backup_mongo/pbm_config_payload", - } - return task - - -def test_pbm_backups_create_full_form_dependency_chain_without_payload_override( - test_client, - mock_task_api_dep, - mock_inventory_api_dep, - backup_create: BackupCreate, - mongo_service: CreatedService, -): - """POST /backup_mongo/ resolves the real dep graph and tags meta with _service_name.""" - mock_inventory_api_dep.get = AsyncMock(return_value=mongo_service.model_dump()) - mock_task_api_dep.post.return_value = AsyncMock() - - response = test_client.post( - "/backup_mongo/", - data=backup_create.model_dump(exclude_none=True), - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - # The route fans out the config task to PBM logical/physical/status sub-tasks. - assert mock_task_api_dep.post.await_count == EXPECTED_PBM_TASK_POSTS - posted = mock_task_api_dep.post.await_args_list[0].kwargs["json"] - assert posted["name"] == backup_create.task_name - assert posted["owner"] == "BACKUP_MONGO" - assert posted["data"]["meta"]["_service_name"] == mongo_service.name - - -def test_pbm_backups_create_rejects_malformed_priority_yaml( - test_client, - mock_task_api_dep, - mock_inventory_api_dep, - backup_create: BackupCreate, - mongo_service: CreatedService, -): - """Reject malformed Node Priority YAML without creating tasks. - - The legacy Jinja path surfaces validation errors as a flash message + 303 redirect - (see the app-wide ``RequestValidationError`` handler), not a raw 422. - """ - mock_inventory_api_dep.get = AsyncMock(return_value=mongo_service.model_dump()) - data = backup_create.model_dump(exclude_none=True) - data["backup_priority"] = '"h1:27018": 2 "h2:27018": 2' - - response = test_client.post("/backup_mongo/", data=data, follow_redirects=False) - - assert response.status_code == status.HTTP_303_SEE_OTHER - mock_task_api_dep.post.assert_not_awaited() - - -def test_pbm_backups_create_rejects_s3_storage_without_bucket( - test_client, - mock_task_api_dep, - mock_inventory_api_dep, - backup_create: BackupCreate, - mongo_service: CreatedService, -): - """Reject an S3 storage config missing a bucket without creating tasks. - - The legacy Jinja path surfaces validation errors as a flash message + 303 - redirect, not a raw 422. - """ - mock_inventory_api_dep.get = AsyncMock(return_value=mongo_service.model_dump()) - data = backup_create.model_dump(exclude_none=True) - data["storage_type"] = "s3" - data["storage_s3_region"] = "eu-west-1" - data.pop("storage_filesystem_path", None) - - response = test_client.post("/backup_mongo/", data=data, follow_redirects=False) - - assert response.status_code == status.HTTP_303_SEE_OTHER - mock_task_api_dep.post.assert_not_awaited() - - -def test_pbm_backups_detail_loads_incremental_history_and_action( - test_client, - mock_task_api_dep, - mock_inventory_api_dep, -): - """GET detail fetches incremental history/running state and exposes the run URL.""" - task = _parent_backup_task() - incremental = TaskFactory.build( - name="mongo-backup-task-incremental", - owner="BACKUP_MONGO", - backend=TaskBackendEnum.PROXY, - ).model_dump(mode="json") - incremental["data"] = { - "task": "run-python", - "backup_type": BackupType.PBM_INCREMENTAL.value, - "parent": "mongo-backup-task", - "meta": task["data"]["meta"], - "payload": "file:///plugins/backup_mongo/pbm_incremental_payload", - } - fetched_paths: list[str] = [] - - async def _mock_get(path: str, **kwargs: Any) -> Any: - fetched_paths.append(path) - if path == "/mongo-backup-task": - return task - if path == "/mongo-backup-task-incremental": - return incremental - if path == "/": - return {"items": [], "total": 0} - if path.startswith("/stats/"): - return {} - if path.endswith("/history/"): - return {"items": []} - raise AssertionError(f"Unexpected path: {path!r}, kwargs={kwargs!r}") - - mock_task_api_dep.get = AsyncMock(side_effect=_mock_get) - mock_inventory_api_dep.get = AsyncMock(return_value={"items": []}) - - response = test_client.get("/backup_mongo/mongo-backup-task") - - assert response.status_code == status.HTTP_200_OK - assert "/mongo-backup-task-incremental" in fetched_paths - assert "/mongo-backup-task-incremental/history/" in fetched_paths - assert "Run Incremental Backup" in response.text - assert "mongo-backup-task-incremental" in response.text - - -def test_pbm_backups_detail_hides_incremental_action_when_sibling_missing( - test_client, - mock_task_api_dep, - mock_inventory_api_dep, -): - """Omit the Run Incremental control when ``-incremental`` has not been backfilled.""" - task = _parent_backup_task() - fetched_paths: list[str] = [] - - async def _mock_get(path: str, **kwargs: Any) -> Any: - fetched_paths.append(path) - if path == "/mongo-backup-task": - return task - if path == "/mongo-backup-task-incremental": - raise HTTPNotFoundException(f"Task not found: {path}") - if path == "/": - return {"items": [], "total": 0} - if path.startswith("/stats/"): - return {} - if path.endswith("/history/"): - return {"items": []} - raise AssertionError(f"Unexpected path: {path!r}, kwargs={kwargs!r}") - - mock_task_api_dep.get = AsyncMock(side_effect=_mock_get) - mock_inventory_api_dep.get = AsyncMock(return_value={"items": []}) - - response = test_client.get("/backup_mongo/mongo-backup-task") - - assert response.status_code == status.HTTP_200_OK - assert "/mongo-backup-task-incremental" in fetched_paths - assert "/mongo-backup-task-incremental/history/" not in fetched_paths - assert "Run Incremental Backup" not in response.text diff --git a/tests/app/sep/apps/backup_pg/conftest.py b/tests/app/sep/apps/backup_pg/conftest.py index 9173ea5a07..47b683ded5 100644 --- a/tests/app/sep/apps/backup_pg/conftest.py +++ b/tests/app/sep/apps/backup_pg/conftest.py @@ -25,14 +25,8 @@ import yaml from app.inventory.models import ServiceTypeEnum -from app.sep.apps.backup_pg.deps import ( - build_backup_task_payload, - get_backups_index_context, - get_backups_task, -) from app.sep.apps.backup_pg.models import BackupPgForm, BackupType -from app.sep.main import sep_app -from app.tasks.models import Task, TaskBackendEnum, TaskWrite +from app.tasks.models import Task from tests.app.factories import MOCK_CREATED_SERVICE_ID, TaskFactory from tests.app.sep.apps.framework.kit import MockInventoryAPI @@ -45,24 +39,6 @@ def mock_inventory_api() -> MockInventoryAPI: return api -@pytest.fixture -def _mock_get_backups_index_context_dep() -> None: - """Mock the get_backups_index_context dependency for the Jinja index route.""" - sep_app.dependency_overrides[get_backups_index_context] = lambda: { - "user": "default_user", - "executor_hosts": [], - "services": [], - "tasks": [], - "history_tasks": [], - "running_tasks": [], - "alert_on_fail_default": False, - "alert_on_fail_available": False, - "connectivity_check_default": True, - } - yield - sep_app.dependency_overrides = {} - - @pytest.fixture def backup_create() -> BackupPgForm: """Define a sample backup_pg create form for the Jinja form-path tests.""" @@ -97,25 +73,3 @@ def created_task() -> Task: } }, ) - - -@pytest.fixture -def _mock_get_backups_task_dep(created_task: Task) -> None: - """Mock the get_backups_task dependency.""" - sep_app.dependency_overrides[get_backups_task] = lambda: created_task - yield - sep_app.dependency_overrides = {} - - -@pytest.fixture -def mock_build_backup_task_payload_dep() -> TaskWrite: - """Mock the build_backup_task_payload dependency for the Jinja create route.""" - fake_task_write = TaskWrite( - name="fake_task", - backend=TaskBackendEnum.PROXY, - owner="BACKUP_PG", - data={"task": "fake-task", "meta": {}, "payload": ""}, - ) - sep_app.dependency_overrides[build_backup_task_payload] = lambda: fake_task_write - yield fake_task_write - sep_app.dependency_overrides = {} diff --git a/tests/app/sep/apps/backup_pg/test_deps.py b/tests/app/sep/apps/backup_pg/test_deps.py index 6cf6adbd28..3d9aaaf5bc 100644 --- a/tests/app/sep/apps/backup_pg/test_deps.py +++ b/tests/app/sep/apps/backup_pg/test_deps.py @@ -21,7 +21,6 @@ from app.core.utils.path import resolve_payload_reference from app.inventory.models import ServiceTypeEnum from app.sep.apps.backup_pg.deps import ( - get_backups_task_info, parse_backup_task_data, ) from app.sep.apps.backup_pg.models import BackupPgForm, BackupType @@ -97,63 +96,6 @@ def test_build_backup_pg_spec_uses_stanza_as_alias(): assert cfg["SERVER_LIST"][0]["ALIAS"] != service.node.address -def test_get_backups_task_info(): - """Test extracting the correct fields from a backup_pg task dictionary.""" - server_port = 5555 - fake_task_dict = { - "data": { - "meta": { - "target": "host.example.com", - "config": yaml.dump( - { - "SERVER_LIST": [ - { - "HOST": "my-db-host", - "PORT": server_port, - "BACKUP_TYPE": BackupType.PGBACKREST.value, - } - ] - } - ), - } - } - } - - result = get_backups_task_info(fake_task_dict) - - assert result["hostname"] == "host.example.com" - assert result["host"] == "my-db-host" - assert result["port"] == server_port - assert result["backup_type"] == BackupType.PGBACKREST.name - - -def test_get_backups_task_info_port_falls_back_to_meta(): - """Test PORT missing from YAML falls back to the meta connectivity port.""" - meta_port = 6543 - fake_task_dict = { - "data": { - "meta": { - "target": "host.example.com", - CONNECTIVITY_META_PORT_KEY: meta_port, - "config": yaml.dump( - { - "SERVER_LIST": [ - { - "HOST": "my-db-host", - "BACKUP_TYPE": BackupType.PGBACKREST.value, - } - ] - } - ), - } - } - } - - result = get_backups_task_info(fake_task_dict) - - assert result["port"] == meta_port - - def test_parse_backup_task_data(): """Test parsing backup task data for the backup_pg detail view.""" expected_port = 5432 diff --git a/tests/app/sep/apps/backup_pg/test_payload_snapshot.py b/tests/app/sep/apps/backup_pg/test_payload_snapshot.py index 02244c3825..58df59b175 100644 --- a/tests/app/sep/apps/backup_pg/test_payload_snapshot.py +++ b/tests/app/sep/apps/backup_pg/test_payload_snapshot.py @@ -17,19 +17,13 @@ Capture the full ``TaskWrite`` envelope produced by both the model-first spec path (``build_backup_pg_spec`` + ``assemble_envelope``) and the legacy Jinja form -path (``deps.build_backup_task_payload``) across a matrix of representative +path across a matrix of representative inputs, and compare each against a committed golden. Both paths feed the same spec builder, so the golden is shared; any drift in the generated ``meta.config`` YAML (or the surrounding envelope) fails loudly. """ -from unittest.mock import AsyncMock - -import pytest - -from app.core.requests.remote_api import RemoteAPI from app.inventory.models import ServiceTypeEnum -from app.sep.apps.backup_pg.deps import build_backup_task_payload from app.sep.apps.backup_pg.models import BackupPgForm from app.sep.apps.backup_pg.spec import build_backup_pg_spec from app.sep.apps.framework.spec import assemble_envelope, ResolvedEntities @@ -135,27 +129,7 @@ def _spec_envelope(case: dict) -> dict: return _normalize(task.model_dump()) -async def _form_envelope(case: dict) -> dict: - """Return the legacy Jinja-path ``TaskWrite`` dump for ``case``. - - Drive ``build_backup_task_payload`` with a boundary inventory mock that serves - the case's deterministic service. - """ - service = _service(case) - inventory = AsyncMock(spec=RemoteAPI) - inventory.get = AsyncMock(return_value=service.model_dump()) - task = await build_backup_task_payload(_form(case), inventory) - return _normalize(task.model_dump()) - - def test_spec_path_payload_matrix_matches_golden(): """Assert the model-first spec path reproduces the frozen envelope matrix.""" payloads = {case["slug"]: _spec_envelope(case) for case in _CASES} assert_or_update(GOLDEN, canonical_json(payloads)) - - -@pytest.mark.asyncio -async def test_form_path_payload_matrix_matches_golden(): - """Assert the legacy Jinja form path reproduces the same frozen envelope matrix.""" - payloads = {case["slug"]: await _form_envelope(case) for case in _CASES} - assert_or_update(GOLDEN, canonical_json(payloads)) diff --git a/tests/app/sep/apps/backup_pg/test_routes.py b/tests/app/sep/apps/backup_pg/test_routes.py deleted file mode 100644 index 0dfec2bfde..0000000000 --- a/tests/app/sep/apps/backup_pg/test_routes.py +++ /dev/null @@ -1,412 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <https://www.gnu.org/licenses/>. - -"""Define tests for the app.sep.apps.backup_pg.routes module.""" - -from unittest.mock import AsyncMock, patch - -import pytest -from fastapi import HTTPException, status - -from app.core.pagination import MAX_PAGINATION_LIMIT -from app.inventory.models import ServiceTypeEnum -from app.sep.apps.backup_pg.deps import ( - build_backup_task_payload, - get_backups_index_context, -) -from app.sep.apps.backup_pg.routes import router as backup_pg_jinja_router -from app.sep.apps.framework.deprecation import DeprecatedJinja2Route -from app.sep.connectivity import ( - clear_connectivity_caches, - get_latest_connectivity_result, -) -from app.sep.main import sep_app -from app.tasks.models import ( - TaskBackendEnum, - TaskHistoryStatusEnum, -) -from tests.app.factories import CreatedServiceFactory, GeneratedTaskFactory - - -@pytest.mark.usefixtures("_mock_get_backups_index_context_dep") -def test_backups_index(test_client): - """Test GET /backup_pg/ route.""" - response = test_client.get("/backup_pg/") - - assert response.status_code == status.HTTP_200_OK - assert response.headers["content-type"] == "text/html; charset=utf-8" - assert ( - "<title>PostgreSQL Backups — Services Enablement Platform" - in response.text - ) - - -def test_backups_create( - test_client, mock_task_api_dep, backup_create, mock_build_backup_task_payload_dep -): - """Test POST /backup_pg/ route.""" - response = test_client.post( - "/backup_pg/", data=backup_create.model_dump(), follow_redirects=False - ) - - assert response.status_code == status.HTTP_303_SEE_OTHER - assert ( - response.headers["location"] - == f"{test_client.base_url}/backup_pg/{backup_create.task_name}" - ) - - mock_task_api_dep.post.assert_called_once() - called_args, called_kwargs = mock_task_api_dep.post.call_args - assert called_args[0] == "/" - assert called_kwargs["json"] == mock_build_backup_task_payload_dep.model_dump() - - -@pytest.mark.usefixtures("_mock_get_backups_index_context_dep") -def test_pg_backups_create_full_form_dependency_chain_without_payload_override( - test_client, - mock_task_api_dep, - mock_inventory_api_dep, - backup_create, - created_node, -): - """Test POST /backup_pg/ route without overriding build_backup_task_payload.""" - pg_service = CreatedServiceFactory.build( - node=created_node, type=ServiceTypeEnum.POSTGRESQL - ) - backup_create.service_id = pg_service.id - mock_inventory_api_dep.get = AsyncMock(return_value=pg_service.model_dump()) - mock_task_api_dep.post.return_value = AsyncMock() - - response = test_client.post( - "/backup_pg/", - data=backup_create.model_dump(), - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"].endswith( - f"/backup_pg/{backup_create.task_name}" - ) - mock_task_api_dep.post.assert_awaited_once() - assert mock_task_api_dep.post.await_args.args[0] == "/" - posted = mock_task_api_dep.post.await_args.kwargs["json"] - assert posted["name"] == backup_create.task_name - assert posted["owner"] == "BACKUP_PG" - assert posted["data"]["meta"]["_service_name"] == pg_service.name - - -def test_pg_backups_create_skips_connectivity_check_when_opted_out( - test_client, mock_task_api_dep, backup_create -): - """POST /backup_pg/ skips the connectivity check when the checkbox is unchecked.""" - clear_connectivity_caches() - - fake_task_write = GeneratedTaskFactory.build( - name="fake_task", - backend=TaskBackendEnum.PROXY, - owner="BACKUP_PG", - data={ - "task": "fake-task", - "meta": { - "target": "node1", - "_connectivity_host": "10.0.0.1", - "_connectivity_port": 5432, - "_connectivity_service_type": ServiceTypeEnum.POSTGRESQL.value, - }, - "payload": "", - }, - ) - - sep_app.dependency_overrides[build_backup_task_payload] = lambda: fake_task_write - - response = test_client.post( - "/backup_pg/", data=backup_create.model_dump(), follow_redirects=False - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert ( - response.headers["location"] - == f"{test_client.base_url}/backup_pg/{backup_create.task_name}" - ) - - assert mock_task_api_dep.post.call_count == 1 - call = mock_task_api_dep.post.call_args_list[0] - assert call.args[0] == "/" - assert call.kwargs["json"] == fake_task_write.model_dump() - assert get_latest_connectivity_result("node1", "postgresql") is None - - clear_connectivity_caches() - sep_app.dependency_overrides = {} - - -@pytest.mark.usefixtures("_mock_get_backups_task_dep", "mock_get_username_mapping") -def test_backups_detail( - test_client, mock_task_api_dep, mock_inventory_api_dep, created_task -): - """Test GET /backup_pg/{task_name} route.""" - mock_task_api_dep.get = AsyncMock( - side_effect=[ - {}, - {"items": [], "total": 0, "offset": 0, "limit": 50}, - {"items": [], "total": 0, "offset": 0, "limit": 50}, - [], - {"items": [], "total": 0, "offset": 0, "limit": 50}, - ] - ) - mock_inventory_api_dep.get = AsyncMock( - return_value={"items": [], "total": 0, "offset": 0, "limit": 50} - ) - - response = test_client.get(f"/backup_pg/{created_task.name}") - - assert response.status_code == status.HTTP_200_OK - assert ( - f"Backups - {created_task.name} — Services Enablement Platform" - in response.text - ) - - mock_task_api_dep.get.assert_any_call(f"/{created_task.name}/history/") - mock_task_api_dep.get.assert_any_call( - f"/{created_task.name}/history/", - params={"status": TaskHistoryStatusEnum.RUNNING}, - ) - mock_task_api_dep.get.assert_any_call(f"/stats/{created_task.name}") - - -@pytest.mark.usefixtures("_mock_get_backups_task_dep", "mock_get_username_mapping") -def test_backups_detail_handles_inventory_error( - test_client, mock_task_api_dep, mock_inventory_api_dep, created_task -): - """Test detail route continues when inventory service lookup fails.""" - mock_task_api_dep.get = AsyncMock( - side_effect=[ - {}, - {"items": [], "total": 0, "offset": 0, "limit": 50}, - {"items": [], "total": 0, "offset": 0, "limit": 50}, - [], - {"items": [], "total": 0, "offset": 0, "limit": 50}, - ] - ) - mock_inventory_api_dep.get = AsyncMock( - side_effect=HTTPException(status_code=status.HTTP_404_NOT_FOUND) - ) - - response = test_client.get(f"/backup_pg/{created_task.name}") - - assert response.status_code == status.HTTP_200_OK - mock_inventory_api_dep.get.assert_any_call( - "/services/", - params={ - "service_type": ServiceTypeEnum.POSTGRESQL, - "offset": 0, - "limit": MAX_PAGINATION_LIMIT, - }, - ) - - -@pytest.mark.parametrize( - ("form_data", "expected_json"), - [ - ( - {}, - {"eta": None, "chain_task_names": None, "chain_on_failure": None}, - ), - ( - {"chain_task_names": ["task-a", "task-b"]}, - { - "eta": None, - "chain_task_names": ["task-a", "task-b"], - "chain_on_failure": None, - }, - ), - ], - ids=["no_chain", "with_chain"], -) -@pytest.mark.usefixtures( - "_mock_get_backups_task_dep", "_mock_check_for_conflicted_running_tasks" -) -def test_backups_execute( - test_client, mock_task_api_dep, created_task, form_data, expected_json -): - """Test POST /backup_pg/{task_name} forwards the form payload to the tasks API.""" - response = test_client.post( - f"/backup_pg/{created_task.name}", - data=form_data, - follow_redirects=False, - ) - - assert response.status_code == status.HTTP_303_SEE_OTHER - assert ( - response.headers["location"] - == f"{test_client.base_url}/backup_pg/{created_task.name}" - ) - - mock_task_api_dep.post.assert_called_once() - called_args, called_kwargs = mock_task_api_dep.post.call_args - assert called_args[0] == f"/execute/{created_task.name}" - assert called_kwargs["json"] == expected_json - - -@pytest.mark.usefixtures("_mock_get_backups_task_dep") -def test_pg_backups_delete(test_client, mock_task_api_dep, created_task): - """Test POST /backup_pg/{task_name}/delete route.""" - response = test_client.post( - f"/backup_pg/{created_task.name}/delete", follow_redirects=False - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["Location"] == "/backup_pg" - - mock_task_api_dep.delete.assert_awaited_once() - called_args, _ = mock_task_api_dep.delete.await_args - assert called_args[0] == f"/{created_task.name}" - - -@pytest.mark.usefixtures("_mock_get_backups_task_dep", "mock_get_username_mapping") -def test_pg_backups_detail_uses_own_delete_route( - test_client, mock_task_api_dep, mock_inventory_api_dep, created_task -): - """Test pg_backups_detail uses its own delete route. - - Regression guard: pg_backups_detail must use pg_backups_delete, - not mysql_backups_delete, so the page renders even when mysql_backups - plugin is disabled. - """ - mock_task_api_dep.get = AsyncMock( - side_effect=[ - {}, - {"items": [], "total": 0, "offset": 0, "limit": 50}, - {"items": [], "total": 0, "offset": 0, "limit": 50}, - [], - {"items": [], "total": 0, "offset": 0, "limit": 50}, - ] - ) - mock_inventory_api_dep.get = AsyncMock( - return_value={"items": [], "total": 0, "offset": 0, "limit": 50} - ) - - response = test_client.get(f"/backup_pg/{created_task.name}") - - assert response.status_code == status.HTTP_200_OK - assert f"/backup_pg/{created_task.name}/delete" in response.text - # No cross-plugin URLs that include the task name (delete/update/detail). - # The page may still contain `/mysql_backups/` from the global plugin nav - # sidebar when mysql_backups is mounted; we only guard the task-specific - # URLs that historically used `url_for("mysql_backups_*", task_name=...)`. - assert f"/mysql_backups/{created_task.name}" not in response.text - # The PG details page does not ship an edit form, so the Edit button must - # be hidden (gated on `is_edit_form_present`). The Delete button must - # still render so users can delete tasks via the UI. - assert "delete-tasks-button" in response.text - assert "edit-tasks-button" not in response.text - - -def test_pg_backups_index_links_periodic_tasks_to_own_detail_route(test_client): - """Test the index page links periodic tasks to its own detail route. - - Regression guard: the periodic-task rows on the PG backups index - page must link to pg_backups_detail, not mysql_backups_detail, so the page - renders even when the mysql_backups plugin is disabled. - """ - sep_app.dependency_overrides[get_backups_index_context] = lambda: { - "user": "default_user", - "executor_hosts": ["host1"], - "services": [], - # A non-empty `tasks` list forces the template to render the "Saved" - # branch, which is what includes the Periodic Tasks partial we need - # to exercise for this regression. - "tasks": [{"name": "pg_periodic_task"}], - "pending_tasks": [], - "history_tasks": [], - "running_tasks": [], - "periodic_tasks": [ - { - "id": 1, - "task": "pg_periodic_task", - "interval": {"every": 5, "period": "minutes"}, - "crontab": None, - "period": "every 5 minutes", - "start_time": None, - "last_run_at": None, - "next_run_at": None, - "total_run_count": 0, - "enabled": True, - "execute_request": None, - }, - ], - "chainable_tasks": [], - "AVAILABLE_TIMEZONES": ["UTC"], - "alert_on_fail_default": False, - "alert_on_fail_available": False, - "connectivity_check_default": True, - } - - response = test_client.get("/backup_pg/") - - assert response.status_code == status.HTTP_200_OK - assert "/backup_pg/pg_periodic_task" in response.text - assert "/mysql_backups/pg_periodic_task" not in response.text - - sep_app.dependency_overrides = {} - - -def test_jinja_routes_omitted_from_openapi(test_client): - """Verify migrated Jinja routes are excluded from the OpenAPI schema. - - Regression guard: routes using ``DeprecatedJinja2Route`` must not appear - in the generated OpenAPI spec now that the React ``backup_pg`` plugin - replaces them. - """ - spec = test_client.get("/openapi.json").json() - - legacy_paths = [ - ("/backup_pg/", "get"), - ("/backup_pg/", "post"), - ("/backup_pg/{task_name}", "get"), - ("/backup_pg/{task_name}", "post"), - ("/backup_pg/{task_name}/delete", "post"), - ] - for path, method in legacy_paths: - operations = spec["paths"].get(path, {}) - assert method not in operations, ( - f"{method.upper()} {path} should be omitted from OpenAPI spec" - ) - - -class TestBackupPgRouterDeprecation: - """Cover the deprecation contract of the backup_pg Jinja2 router.""" - - def test_router_uses_deprecated_route_class(self): - """Confirm the router is constructed with ``DeprecatedJinja2Route``.""" - assert backup_pg_jinja_router.route_class is DeprecatedJinja2Route - assert backup_pg_jinja_router.routes - - @pytest.mark.parametrize( - "route", - backup_pg_jinja_router.routes, - ids=lambda route: f"{sorted(route.methods)[0]} {route.path}", - ) - def test_every_route_is_built_by_the_deprecation_route_class(self, route): - """Confirm every registered route inherits the header, log, and schema exclusion.""" - assert isinstance(route, DeprecatedJinja2Route) - assert route.include_in_schema is False - - @pytest.mark.usefixtures("_mock_get_backups_index_context_dep") - def test_legacy_route_sets_deprecation_header_and_logs(self, test_client): - """Assert a real backup_pg Jinja2 route emits the deprecation contract.""" - with patch("app.sep.apps.framework.deprecation.logger.warning") as warning: - response = test_client.get("/backup_pg/") - - assert response.status_code == status.HTTP_200_OK - assert response.headers["Deprecation"] == "true" - warning.assert_called_once() - assert "/backup_pg/" in warning.call_args.args[0] diff --git a/tests/app/sep/apps/checksums/test_deps.py b/tests/app/sep/apps/checksums/test_deps.py index eeaaf909de..15cfa1eef0 100644 --- a/tests/app/sep/apps/checksums/test_deps.py +++ b/tests/app/sep/apps/checksums/test_deps.py @@ -15,330 +15,9 @@ """Define tests for the app.sep.apps.checksums.deps module.""" -import shlex -from unittest.mock import AsyncMock - -import pytest - from app.sep.apps.checksums.deps import ( - assemble_checksum_payload, - build_checksums_task_payload, - legacy_checksums_create_to_form, parse_checksums_task_args, ) -from app.sep.apps.checksums.models import ChecksumsCreate, ChecksumsForm -from app.sep.apps.checksums.spec import ( - build_checksums_spec, - resolve_checksums_target_args, -) -from app.sep.apps.framework.form_dsl import DSN_TABLE_DEFAULT -from app.sep.apps.framework.spec import assemble_envelope, ResolvedEntities -from app.tasks.models import TaskWrite - - -class TestChecksumsJinjaFormDeps: - """Tests for the Jinja2 / HTML-form path (build_checksums_task_payload).""" - - @pytest.mark.asyncio - async def test_build_checksums_task_payload_dsn_recursion_defaults_empty_dsn_table( - self, - created_service, - mock_remote_api, - ): - """Assert blank dsn_table defaults to D=percona,t=dsns in the checksums command.""" - mock_remote_api.get = AsyncMock(return_value=created_service.model_dump()) - created_checksums = ChecksumsCreate( - task_name="checksums-test", - hostname="localhost", - service_id=created_service.id, - recursion_method="dsn", - dsn_table="", - ) - - generated_task = await build_checksums_task_payload( - created_checksums, - mock_remote_api, - ) - - assert isinstance(generated_task, TaskWrite) - assert generated_task.owner == "CHECKSUMS" - args = shlex.split(generated_task.data["meta"]["args"]) - recursion_arg = next( - (arg for arg in args if arg.startswith("--recursion-method=")), - "", - ) - assert recursion_arg - assert DSN_TABLE_DEFAULT in recursion_arg - - -class TestLegacyChecksumsCreateToForm: - """Cover legacy Jinja POST → ``ChecksumsForm`` mapping.""" - - def test_merges_text_fields_and_extra_args_targets(self) -> None: - """Merge databases/tables inputs with matching extra_args tokens.""" - flat = ChecksumsCreate( - task_name="chk", - hostname="host1", - service_id=1, - recursion_method="processlist", - databases="db1", - tables="db1.t1", - extra_args="--databases=db2 --tables=db2.t2 --chunk-time=1", - ) - form, remaining = legacy_checksums_create_to_form(flat) - - assert form.databases == ["db1", "db2"] - assert form.tables == ["db1.t1", "db2.t2"] - assert remaining == ["--chunk-time=1"] - - def test_merges_legacy_inventory_ids(self) -> None: - """Append legacy schema_id / table_id selections as inventory refs.""" - flat = ChecksumsCreate( - task_name="chk", - hostname="host1", - service_id=1, - recursion_method="processlist", - schema_id={10, 20}, - table_id={30}, - ) - form, remaining = legacy_checksums_create_to_form(flat) - - assert form.databases == [10, 20] - assert form.tables == [30] - assert remaining == [] - - -class TestChecksumsNomadPayloadParity: - """Assert the Jinja form path and the model-first spec path are byte-identical.""" - - @pytest.mark.asyncio - async def test_form_and_spec_paths_produce_identical_task_write( - self, - created_service, - mock_remote_api, - ): - """Assert the form payload and the spec-built envelope produce identical TaskWrite. - - The model-first JSON create path runs the ``ChecksumsForm`` through - ``build_checksums_spec`` + the framework's ``assemble_envelope``; the - Jinja path runs ``ChecksumsCreate`` through ``build_checksums_task_payload``. - Both must produce the same Nomad payload for the same inputs. - """ - service_dump = created_service.model_dump() - mock_remote_api.get = AsyncMock(return_value=service_dump) - - common_fields = { - "task_name": "parity-check", - "hostname": "host1", - "service_id": created_service.id, - "databases": "mydb", - "tables": "mydb.mytable", - "recursion_method": "processlist", - "dsn_table": "", - "pause_file": "", - "binary_index": False, - "explain_arg": False, - "fail_on_stopped_replication": False, - "truncate_replicate_table": False, - "progress": "time,10", - "set_vars": "", - "max_load": "Threads_running=50", - "chunk_time": "0.5", - "max_lag": "150", - "alert_on_fail": False, - } - - form_result = await build_checksums_task_payload( - ChecksumsCreate(**common_fields, extra_args=""), mock_remote_api - ) - - checksums_form = ChecksumsForm.model_validate(common_fields) - resolved = ResolvedEntities( - service=created_service, - entities={"service_id": created_service}, - executor_host=common_fields["hostname"], - ) - databases_arg, tables_arg = await resolve_checksums_target_args( - checksums_form, mock_remote_api - ) - spec_result = assemble_envelope( - build_checksums_spec( - checksums_form, - resolved, - databases_arg=databases_arg, - tables_arg=tables_arg, - ), - resolved, - name=common_fields["task_name"], - owner="CHECKSUMS", - alert_on_fail=common_fields["alert_on_fail"], - ) - - assert form_result.model_dump() == spec_result.model_dump() - - -class TestChecksumsPayloadAssembly: - """Cover the shared ``assemble_checksum_payload`` helper.""" - - @staticmethod - def _assemble(created_service, **overrides): - """Run ``assemble_checksum_payload`` with default kwargs, applying overrides. - - :param created_service: the service fixture forwarded as the first positional arg. - :param overrides: keyword arguments overriding the assembled defaults. - """ - databases = overrides.pop("databases", "") - tables = overrides.pop("tables", "") - kwargs = { - "task_name": "test", - "hostname": "host1", - "service_id": created_service.id, - "recursion_method": "processlist", - "dsn_table": "", - "pause_file": "", - "binary_index": False, - "explain_arg": False, - "fail_on_stopped_replication": False, - "truncate_replicate_table": False, - "progress": "", - "set_vars": "", - "max_load": "", - "chunk_time": "", - "max_lag": "", - "alert_on_fail": False, - } - kwargs.update(overrides) - form = ChecksumsForm.model_validate( - {**kwargs, "databases": databases, "tables": tables} - ) - databases_arg = ",".join(str(value) for value in form.databases) - tables_arg = ",".join(str(value) for value in form.tables) - return assemble_checksum_payload( - created_service, - form, - databases_arg=databases_arg, - tables_arg=tables_arg, - ) - - def test_dsn_expansion_does_not_mutate_input(self, created_service): - """Assert assemble_checksum_payload does not mutate the recursion_method argument.""" - original_recursion_method = "dsn" - - assemble_checksum_payload( - created_service, - ChecksumsForm( - task_name="test", - hostname="host1", - service_id=created_service.id, - recursion_method=original_recursion_method, - ), - databases_arg="", - tables_arg="", - ) - - assert original_recursion_method == "dsn" - - @pytest.mark.parametrize( - ("dsn_table_input", "expected_substring"), - [ - ("", DSN_TABLE_DEFAULT), - ("D=mydb,t=custom_dsns", "D=mydb,t=custom_dsns"), - ], - ids=["default_when_empty", "provided_verbatim"], - ) - def test_dsn_expansion_recursion_arg( - self, dsn_table_input, expected_substring, created_service - ): - """Assert dsn expansion uses the default dsn_table when empty, else the provided value verbatim.""" - result = self._assemble( - created_service, recursion_method="dsn", dsn_table=dsn_table_input - ) - - args = shlex.split(result.data["meta"]["args"]) - recursion_arg = next( - (a for a in args if a.startswith("--recursion-method=")), "" - ) - assert expected_substring in recursion_arg - - @pytest.mark.parametrize( - "recursion_method", ["processlist", "hosts", "none", "default"] - ) - def test_non_dsn_recursion_methods_are_not_expanded( - self, recursion_method, created_service - ): - """Assert non-DSN recursion methods are forwarded as-is without dsn= expansion.""" - result = self._assemble( - created_service, - recursion_method=recursion_method, - dsn_table="D=percona,t=dsns", - ) - - args_str = result.data["meta"]["args"] - assert "dsn=" not in args_str - - @pytest.mark.parametrize( - ("overrides", "flags", "should_appear"), - [ - ( - {}, - ["--databases", "--tables", "--pause-file", "--progress", "--set-vars"], - False, - ), - ( - { - "binary_index": False, - "explain_arg": False, - "fail_on_stopped_replication": False, - "truncate_replicate_table": False, - }, - [ - "--binary-index", - "--explain", - "--fail-on-stopped-replication", - "--truncate-replicate-table", - ], - False, - ), - ( - { - "binary_index": True, - "explain_arg": True, - "fail_on_stopped_replication": True, - "truncate_replicate_table": True, - }, - [ - "--binary-index", - "--explain", - "--fail-on-stopped-replication", - "--truncate-replicate-table", - ], - True, - ), - ], - ids=[ - "optional_strings_omitted", - "bool_flags_omitted_when_false", - "bool_flags_present_when_true", - ], - ) - def test_optional_and_flag_fields_presence_follows_value( - self, overrides, flags, should_appear, created_service - ): - """Assert optional string and boolean flags appear in args only when set. - - Empty optional string fields and ``False`` boolean flags are omitted; - ``True`` boolean flags are emitted. The ``_assemble`` defaults supply - empty strings and ``False`` flags, so each case only overrides what it - exercises. - """ - result = self._assemble(created_service, **overrides) - - args = shlex.split(result.data["meta"]["args"]) - for flag in flags: - present = any(a.startswith(flag) for a in args) - assert present is should_appear, ( - f"{flag} present={present}, expected {should_appear}" - ) class TestParseChecksumsTaskArgs: diff --git a/tests/app/sep/apps/checksums/test_payload_snapshot.py b/tests/app/sep/apps/checksums/test_payload_snapshot.py index 2de22f48c4..a58cf43e96 100644 --- a/tests/app/sep/apps/checksums/test_payload_snapshot.py +++ b/tests/app/sep/apps/checksums/test_payload_snapshot.py @@ -15,23 +15,15 @@ """Freeze the byte-identity guardrail for the checksums Nomad payload. -Capture the full ``TaskWrite`` envelope produced by both the model-first spec -path (``build_checksums_spec`` + ``assemble_envelope``) and the legacy Jinja form -path (``assemble_checksum_payload``) across a matrix of representative inputs, -and compare each against a committed golden. A declarative rewrite that shifts -both code paths together would slip past the form-vs-spec parity test in -``test_deps.py``; this golden is captured from a known-good baseline so any drift -in the generated ``meta.args`` (or the surrounding envelope) fails loudly. +Capture the full ``TaskWrite`` envelope produced by the model-first spec path +(``build_checksums_spec`` + ``assemble_envelope``) across a matrix of +representative inputs, and compare each against a committed golden, captured +from a known-good baseline so any drift in the generated ``meta.args`` (or the +surrounding envelope) fails loudly. """ -from unittest.mock import AsyncMock - -import pytest - -from app.core.requests.remote_api import RemoteAPI from app.inventory.models import ServiceTypeEnum -from app.sep.apps.checksums.deps import build_checksums_task_payload -from app.sep.apps.checksums.models import ChecksumsCreate, ChecksumsForm +from app.sep.apps.checksums.models import ChecksumsForm from app.sep.apps.checksums.spec import build_checksums_spec from app.sep.apps.framework.spec import assemble_envelope, ResolvedEntities from app.sep.inventory import CreatedService @@ -165,42 +157,6 @@ def _spec_envelope(case: dict) -> dict: return task.model_dump() -async def _form_envelope(case: dict) -> dict: - """Return the legacy Jinja-path ``TaskWrite`` dump for ``case``. - - Drive the public ``build_checksums_task_payload`` entry point with a boundary - inventory mock that serves the case's deterministic service. - """ - service = _service(case) - inventory = AsyncMock(spec=RemoteAPI) - inventory.get = AsyncMock(return_value=service.model_dump()) - form = ChecksumsCreate( - task_name=_TASK_NAME, - hostname=_HOSTNAME, - service_id=service.id, - recursion_method=case["form"].get("recursion_method", "processlist"), - dsn_table=case["form"].get("dsn_table", ""), - databases=case["form"].get("databases", ""), - tables=case["form"].get("tables", ""), - pause_file=case["form"].get("pause_file", ""), - binary_index=case["form"].get("binary_index", False), - explain_arg=case["form"].get("explain_arg", False), - fail_on_stopped_replication=case["form"].get( - "fail_on_stopped_replication", False - ), - truncate_replicate_table=case["form"].get("truncate_replicate_table", False), - progress=case["form"].get("progress", ""), - set_vars=case["form"].get("set_vars", ""), - max_load=case["form"].get("max_load", ""), - chunk_time=case["form"].get("chunk_time", ""), - max_lag=case["form"].get("max_lag", ""), - alert_on_fail=case["alert_on_fail"], - extra_args=case.get("extra_args", ""), - ) - task = await build_checksums_task_payload(form, inventory) - return task.model_dump() - - def test_spec_path_payload_matrix_matches_golden(): """Assert the model-first spec path reproduces the frozen envelope matrix.""" payloads = { @@ -211,12 +167,3 @@ def test_spec_path_payload_matrix_matches_golden(): assert_or_update( PAYLOAD_DIR / "checksums__spec_path.json", canonical_json(payloads) ) - - -@pytest.mark.asyncio -async def test_form_path_payload_matrix_matches_golden(): - """Assert the legacy Jinja form path reproduces the frozen envelope matrix.""" - payloads = {case["slug"]: await _form_envelope(case) for case in _CASES} - assert_or_update( - PAYLOAD_DIR / "checksums__form_path.json", canonical_json(payloads) - ) diff --git a/tests/app/sep/apps/checksums/test_routes.py b/tests/app/sep/apps/checksums/test_routes.py deleted file mode 100644 index ffc2affe06..0000000000 --- a/tests/app/sep/apps/checksums/test_routes.py +++ /dev/null @@ -1,357 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define tests for the app.sep.apps.checksums.routes module.""" - -from unittest.mock import AsyncMock - -import pytest -from fastapi import status - -from app.inventory.models import ServiceTypeEnum -from app.sep.apps.checksums.deps import ( - build_checksums_task_payload, - get_checksums_index_context, - get_checksums_task, -) -from app.sep.apps.checksums.models import ChecksumsCreate -from app.sep.connectivity import ( - clear_connectivity_caches, - get_latest_connectivity_result, -) -from app.sep.main import sep_app -from app.tasks.models import TaskBackendEnum -from tests.app.factories import ( - CreatedNodeFactory, - CreatedServiceFactory, - GeneratedTaskFactory, - TaskFactory, -) - - -@pytest.fixture -def checksums_create(): - """Define a sample ChecksumsCreate form data.""" - return ChecksumsCreate( - task_name="fake_checksum", - hostname="localhost", - service_id=1, - recursion_method="processlist", - ) - - -def test_checksums_create_full_form_dependency_chain_without_payload_override( - test_client, - mock_task_api_dep, - mock_inventory_api_dep, - created_service, -): - """Test POST /checksums/ route without overriding build_checksums_task_payload.""" - checksums_create = ChecksumsCreate( - task_name="chk_full_chain", - hostname="localhost", - service_id=created_service.id, - recursion_method="processlist", - ) - mock_inventory_api_dep.get = AsyncMock(return_value=created_service.model_dump()) - mock_task_api_dep.post.return_value = AsyncMock() - - response = test_client.post( - "/checksums/", - data=checksums_create.model_dump(exclude_none=True), - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"].endswith("/checksums/chk_full_chain") - mock_task_api_dep.post.assert_awaited_once() - posted = mock_task_api_dep.post.await_args.kwargs["json"] - assert posted["name"] == "chk_full_chain" - assert posted["owner"] == "CHECKSUMS" - - -class TestChecksumsUpdateFormChain: - """Exercise the real ``Form()`` dependency chain on POST /checksums/{task_name}/update.""" - - def test_full_form_dependency_chain_without_payload_override( - self, - test_client, - mock_task_api_dep, - mock_inventory_api_dep, - created_service, - ): - """Forward the submitted body to ``tasks_api.put`` through the real form chain.""" - task_name = "chk_update_full_chain" - checksums_create = ChecksumsCreate( - task_name=task_name, - hostname="localhost", - service_id=created_service.id, - recursion_method="processlist", - ) - mock_inventory_api_dep.get = AsyncMock( - return_value=created_service.model_dump() - ) - mock_task_api_dep.put.return_value = AsyncMock() - - response = test_client.post( - f"/checksums/{task_name}/update", - data=checksums_create.model_dump(exclude_none=True), - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"].endswith(f"/checksums/{task_name}") - mock_task_api_dep.put.assert_awaited_once() - assert mock_task_api_dep.put.await_args.args[0] == f"/{task_name}" - put_payload = mock_task_api_dep.put.await_args.kwargs["json"] - assert put_payload["name"] == task_name - assert put_payload["owner"] == "CHECKSUMS" - # Submitted ``recursion_method`` must survive the real form chain into the args. - assert "--recursion-method=processlist" in put_payload["data"]["meta"]["args"] - - def test_forwards_path_name_but_redirects_to_form_name( - self, - test_client, - mock_task_api_dep, - mock_inventory_api_dep, - created_service, - ): - """Forward the path ``task_name`` to put while the form name drives the redirect.""" - path_name = "chk_url_path" - form_name = "chk_form_name" - checksums_create = ChecksumsCreate( - task_name=form_name, - hostname="localhost", - service_id=created_service.id, - recursion_method="processlist", - ) - mock_inventory_api_dep.get = AsyncMock( - return_value=created_service.model_dump() - ) - mock_task_api_dep.put.return_value = AsyncMock() - - response = test_client.post( - f"/checksums/{path_name}/update", - data=checksums_create.model_dump(exclude_none=True), - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert mock_task_api_dep.put.await_args.args[0] == f"/{path_name}" - assert mock_task_api_dep.put.await_args.kwargs["json"]["name"] == form_name - assert response.headers["location"].endswith(f"/checksums/{form_name}") - - def test_invalid_form_flashes_and_redirects_without_forwarding( - self, - test_client, - mock_task_api_dep, - mock_inventory_api_dep, - created_service, - ): - """Flash and redirect (never forward) when the update form fails validation.""" - data = { - "task_name": "chk_invalid", - "hostname": "localhost", - "service_id": created_service.id, - # recursion_method intentionally omitted - } - mock_inventory_api_dep.get = AsyncMock( - return_value=created_service.model_dump() - ) - - response = test_client.post( - "/checksums/chk_invalid/update", data=data, follow_redirects=False - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"] == "/" - assert "messages=" in response.headers.get("set-cookie", "") - mock_task_api_dep.put.assert_not_awaited() - - -def test_checksums_create_skips_connectivity_check_when_opted_out( - test_client, mock_task_api_dep, checksums_create -): - """POST /checksums/ skips the connectivity check when the checkbox is unchecked.""" - clear_connectivity_caches() - - fake_task_write = GeneratedTaskFactory.build( - name="fake_checksum", - backend=TaskBackendEnum.PROXY, - owner="CHECKSUMS", - data={ - "task": "run-command", - "meta": { - "command": "pt-table-checksum", - "args": "--recursion-method=processlist", - "target": "node1", - "_connectivity_host": "10.0.0.1", - "_connectivity_port": 3306, - "_connectivity_service_type": ServiceTypeEnum.MYSQL.value, - }, - }, - ) - - sep_app.dependency_overrides[build_checksums_task_payload] = lambda: fake_task_write - - response = test_client.post( - "/checksums/", data=checksums_create.model_dump(), follow_redirects=False - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert ( - response.headers["location"] - == f"{test_client.base_url}/checksums/{checksums_create.task_name}" - ) - - assert mock_task_api_dep.post.call_count == 1 - call = mock_task_api_dep.post.call_args_list[0] - assert call.args[0] == "/" - assert call.kwargs["json"] == fake_task_write.model_dump() - assert get_latest_connectivity_result("node1", "mysql") is None - - clear_connectivity_caches() - sep_app.dependency_overrides = {} - - -def _deterministic_mysql_service(): - """Build a MySQL service with a fixed host/port for byte-exact arg assertions.""" - return CreatedServiceFactory.build( - node=CreatedNodeFactory.build(address="db-host"), - type=ServiceTypeEnum.MYSQL, - name="svc", - port=3306, - ) - - -def test_legacy_create_assembles_exact_args( - test_client, mock_task_api_dep, mock_inventory_api_dep -): - """Assert POST /checksums/ (form) assembles the byte-exact pt-table-checksum args.""" - service = _deterministic_mysql_service() - mock_inventory_api_dep.get = AsyncMock(return_value=service.model_dump()) - mock_task_api_dep.post.return_value = AsyncMock() - - response = test_client.post( - "/checksums/", - data={ - "task_name": "chk-legacy", - "hostname": "exec-node", - "service_id": service.id, - "recursion_method": "processlist", - "databases": "db1", - }, - follow_redirects=False, - ) - - assert response.status_code == status.HTTP_303_SEE_OTHER - posted = mock_task_api_dep.post.await_args.kwargs["json"] - assert posted["data"]["meta"]["args"] == ( - "h=db-host,P=3306, --recursion-method=processlist --databases=db1" - ) - - -def test_legacy_update_assembles_exact_args( - test_client, mock_task_api_dep, mock_inventory_api_dep -): - """Assert POST /checksums/{task_name}/update (form) reuses the byte-exact args.""" - service = _deterministic_mysql_service() - mock_inventory_api_dep.get = AsyncMock(return_value=service.model_dump()) - mock_task_api_dep.put.return_value = AsyncMock() - - response = test_client.post( - "/checksums/chk-legacy/update", - data={ - "task_name": "chk-legacy", - "hostname": "exec-node", - "service_id": service.id, - "recursion_method": "hosts", - "tables": "db.t1", - }, - follow_redirects=False, - ) - - assert response.status_code == status.HTTP_303_SEE_OTHER - put_json = mock_task_api_dep.put.await_args.kwargs["json"] - assert put_json["data"]["meta"]["args"] == ( - "h=db-host,P=3306, --recursion-method=hosts --tables=db.t1" - ) - - -@pytest.fixture -def created_checksums_task(): - """Return a fake Checksums task with renderable detail-page data.""" - return TaskFactory.build( - owner="CHECKSUMS", - data={ - "meta": { - "command": "pt-table-checksum", - "args": "h=localhost,P=3306", - "target": "localhost", - } - }, - ) - - -@pytest.fixture -def _mock_get_checksums_task_dep(created_checksums_task): - """Mock the checksums task dependency with a built task.""" - sep_app.dependency_overrides[get_checksums_task] = lambda: created_checksums_task - yield - sep_app.dependency_overrides = {} - - -@pytest.mark.usefixtures("_mock_get_checksums_task_dep", "mock_get_username_mapping") -def test_checksums_detail_uses_own_delete_route( - test_client, mock_task_api_dep, mock_inventory_api_dep, created_checksums_task -): - """Assert checksums_detail renders its own delete route, not tasks_delete. - - Regression guard: the shared ``run-python-form`` partial dropped its - ``tasks_delete`` fallback, so the rendered delete form must point at the - owner's ``checksums_delete`` URL carried on ``task.delete_url``. - """ - mock_task_api_dep.get = AsyncMock( - side_effect=[ - {}, # /hosts/ - {"items": [], "total": 0, "offset": 0, "limit": 50}, # history - {"items": [], "total": 0, "offset": 0, "limit": 50}, # running_tasks - [], # stats - {"items": [], "total": 0, "offset": 0, "limit": 50}, # chainable_tasks - ] - ) - mock_inventory_api_dep.get = AsyncMock( - return_value={"items": [], "total": 0, "offset": 0, "limit": 50} - ) - - response = test_client.get(f"/checksums/{created_checksums_task.name}") - - assert response.status_code == status.HTTP_200_OK - assert f"/checksums/{created_checksums_task.name}/delete" in response.text - assert f"/tasks/{created_checksums_task.name}/delete" not in response.text - - -@pytest.fixture -def _mock_get_checksums_index_context_dep(): - """Mock the get_checksums_index_context dependency with a stub context.""" - sep_app.dependency_overrides[get_checksums_index_context] = lambda: { - "user": "default_user", - } - yield - sep_app.dependency_overrides = {} - - -@pytest.mark.usefixtures("_mock_get_checksums_index_context_dep") -def test_checksums_index(test_client): - """GET /checksums/ renders the index via the relocated ChecksumsIndexContext alias.""" - response = test_client.get("/checksums/") - - assert response.status_code == status.HTTP_200_OK - assert response.headers["content-type"] == "text/html; charset=utf-8" diff --git a/tests/app/sep/apps/conftest.py b/tests/app/sep/apps/conftest.py index 276cc9b016..07c1bff2a7 100644 --- a/tests/app/sep/apps/conftest.py +++ b/tests/app/sep/apps/conftest.py @@ -31,7 +31,7 @@ from app.core.auth.providers.casdoor.models import CasdoorUser from app.core.exceptions import HTTPConflictException from app.sep.apps.framework.apps import TaskExecutionApp -from app.sep.deps import check_for_conflicted_running_tasks, get_api_authenticated_user +from app.sep.deps import check_for_conflicted_running_tasks, get_current_user from app.sep.main import sep_app from app.tasks.models import TaskBackendEnum, TaskWrite from tests.app.factories import GeneratedTaskFactory @@ -156,6 +156,10 @@ def unauthenticated_contract_client( request: pytest.FixtureRequest, ) -> TestClient: """Return a contract client whose auth dep raises, to exercise the 401 path.""" + + def _raise_unauthorized() -> None: + raise HTTPUnauthorizedException + app = mount_app(_bound_app_def(request)) - app.dependency_overrides[get_api_authenticated_user] = _raise_unauthorized + app.dependency_overrides[get_current_user] = _raise_unauthorized return TestClient(app, raise_server_exceptions=False) diff --git a/tests/app/sep/apps/dipper/test_deps.py b/tests/app/sep/apps/dipper/test_deps.py index 2bbd69fa4e..4a95a4e862 100644 --- a/tests/app/sep/apps/dipper/test_deps.py +++ b/tests/app/sep/apps/dipper/test_deps.py @@ -30,7 +30,6 @@ fetch_pmm_node_service_names, get_dipper_script_filename, has_pmm_script, - resolve_executor_host_for_service, ) from app.sep.clients.pmm import PMMRemoteAPI from app.sep.inventory import CreatedService @@ -151,65 +150,6 @@ def _make_service_with_node( return CreatedServiceFactory.build(name=service_name, node=node) -class TestResolveExecutorHostForService: - """Tests for ``resolve_executor_host_for_service``.""" - - def test_returns_node_name_when_it_matches_executor_hosts(self) -> None: - """Return ``service.node.name`` when it matches a Nomad node name.""" - service = _make_service_with_node( - node_name="mvc-lab-db3", node_address="10.0.0.7" - ) - executor_hosts = {"mvc-lab-db3": "10.0.0.7"} - assert ( - resolve_executor_host_for_service(executor_hosts, service) == "mvc-lab-db3" - ) - - def test_returns_executor_name_when_node_address_matches(self) -> None: - """Resolve via address when inventory name differs from Nomad node name. - - Inventory records ``mvc-lab-maria1`` while the Nomad agent registers - ``mvc-lab-db3`` for the same host; the helper must return the - Nomad-keyed name expected by Dipper. - """ - service = _make_service_with_node( - node_name="mvc-lab-maria1", node_address="10.0.0.7" - ) - executor_hosts = {"mvc-lab-db3": "10.0.0.7"} - assert ( - resolve_executor_host_for_service(executor_hosts, service) == "mvc-lab-db3" - ) - - def test_prefers_node_name_over_address_lookup(self) -> None: - """Take ``service.node.name`` over an address lookup that would resolve elsewhere.""" - service = _make_service_with_node( - node_name="mvc-lab-db3", node_address="10.0.0.7" - ) - executor_hosts = {"mvc-lab-db3": "10.0.0.9", "mvc-lab-db5": "10.0.0.7"} - assert ( - resolve_executor_host_for_service(executor_hosts, service) == "mvc-lab-db3" - ) - - def test_returns_service_name_when_node_is_none(self) -> None: - """Fall back to ``service.name`` when ``service.node`` is ``None``.""" - service = _make_service_with_node( - service_name="mvc-lab-db3", node_name=None, node_address=None - ) - executor_hosts = {"mvc-lab-db3": "10.0.0.7"} - assert ( - resolve_executor_host_for_service(executor_hosts, service) == "mvc-lab-db3" - ) - - def test_returns_none_when_nothing_matches(self) -> None: - """Return ``None`` when neither name nor address resolves.""" - service = _make_service_with_node( - service_name="mvc-lab-other", - node_name="mvc-lab-maria1", - node_address="10.0.0.99", - ) - executor_hosts = {"mvc-lab-db3": "10.0.0.7"} - assert resolve_executor_host_for_service(executor_hosts, service) is None - - def _named(*names: str) -> list[SimpleNamespace]: """Build a list of objects exposing a ``name`` attribute.""" return [SimpleNamespace(name=name) for name in names] diff --git a/tests/app/sep/apps/dipper/test_routes.py b/tests/app/sep/apps/dipper/test_routes.py deleted file mode 100644 index 6e5f9be4e1..0000000000 --- a/tests/app/sep/apps/dipper/test_routes.py +++ /dev/null @@ -1,102 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Tests for the dipper plugin Jinja2 routes.""" - -import logging -from types import SimpleNamespace -from unittest.mock import AsyncMock, patch - -import pytest -from fastapi import status - -from app.sep.apps.dipper.deps import ( - get_dipper_execution_meta, - get_dipper_script_with_meta, -) -from app.sep.apps.dipper.routes import router as dipper_jinja_router -from app.sep.apps.framework.deprecation import DeprecatedJinja2Route -from app.sep.main import sep_app -from app.sep.snippets.models.snippet import SnippetExecutionMeta - - -class TestDipperRouterDeprecation: - """The dipper Jinja2 router uses the deprecation route class.""" - - def test_router_uses_deprecated_route_class(self): - """Confirm the router is constructed with ``DeprecatedJinja2Route``.""" - assert dipper_jinja_router.route_class is DeprecatedJinja2Route - - def test_legacy_route_sets_deprecation_header_and_logs( - self, - test_client, - mock_inventory_api_dep, - mock_task_api_dep, - mock_get_username_mapping, - caplog, - ): - """A real Dipper Jinja2 route emits the deprecation contract.""" - mock_inventory_api_dep.get = AsyncMock(return_value={"items": []}) - mock_task_api_dep.get = AsyncMock(return_value={}) - - with ( - caplog.at_level( - logging.WARNING, logger="app.sep.apps.framework.deprecation" - ), - patch("app.sep.apps.framework.deprecation.logger.warning") as warning, - ): - response = test_client.get("/dipper/") - - assert response.status_code == status.HTTP_200_OK - assert response.headers["Deprecation"] == "true" - warning.assert_called_once() - assert "/dipper/" in warning.call_args.args[0] - - -@pytest.fixture -def _mock_dipper_execute_deps(): - """Replace the computed dipper-execute deps so the route resolves without the real chain.""" - sep_app.dependency_overrides[get_dipper_execution_meta] = ( - lambda: SnippetExecutionMeta( - target="node-1", - interpreter="bash", - snippet_source="https://example.com/collect.sh", - snippet_filename="collect.sh", - md5_checksum="d" * 32, - ) - ) - sep_app.dependency_overrides[get_dipper_script_with_meta] = lambda: SimpleNamespace( - execution_task_name="dipper-collect-task" - ) - yield - sep_app.dependency_overrides = {} - - -@pytest.mark.usefixtures("_mock_dipper_execute_deps") -def test_dipper_execute_dispatches_via_relocated_execution_meta_dep( - test_client, mock_task_api_dep -): - """Resolve execution_meta through the relocated ExecutionMetaDep alias on POST /dipper/execute.""" - mock_task_api_dep.post = AsyncMock() - - response = test_client.post( - "/dipper/execute", - params={"service_id": 1}, - follow_redirects=False, - ) - - assert response.status_code == status.HTTP_303_SEE_OTHER - mock_task_api_dep.post.assert_awaited_once() - assert mock_task_api_dep.post.await_args.args[0] == "/execute/dipper-collect-task" diff --git a/tests/app/sep/apps/framework/contract_suite.py b/tests/app/sep/apps/framework/contract_suite.py index b39e4f69f0..ef023ff230 100644 --- a/tests/app/sep/apps/framework/contract_suite.py +++ b/tests/app/sep/apps/framework/contract_suite.py @@ -59,7 +59,7 @@ class TestChecksumsContract(DerivedRouterContractTests): from app.sep.apps.framework.spec import RESERVED_FORM_KEY from app.sep.connectivity import CONNECTIVITY_META_HOST_KEY from app.sep.deps import ( - get_api_authenticated_user, + get_current_user, get_inventory_api, get_tasks_api, IsApiAuthenticated, @@ -185,7 +185,7 @@ def build_contract_client( :return: A bare ``TestClient`` (never a context manager — lifespan trap). """ app = mount_app(app_def) - app.dependency_overrides[get_api_authenticated_user] = lambda: user + app.dependency_overrides[get_current_user] = lambda: user app.dependency_overrides[get_tasks_api] = lambda: tasks_api if inventory_api is not None: app.dependency_overrides[get_inventory_api] = lambda: inventory_api diff --git a/tests/app/sep/apps/framework/test_api.py b/tests/app/sep/apps/framework/test_api.py index e897c45af6..26cc8df1aa 100644 --- a/tests/app/sep/apps/framework/test_api.py +++ b/tests/app/sep/apps/framework/test_api.py @@ -107,7 +107,7 @@ ) from app.sep.deps import ( check_for_conflicted_running_tasks, - get_api_authenticated_user, + get_current_user, get_task_by_name, get_tasks_api, IsApiAuthenticated, @@ -203,24 +203,6 @@ ) -def _register_login_placeholder(app: FastAPI) -> None: - """Register a no-op ``/login`` route so ``LoginRedirectException`` resolves. - - ``LoginRedirectException`` constructs its ``Location`` via - ``request.url_for("login")``; without a named ``login`` route on the app - that call raises ``NoMatchFound`` and masks the real 401 behaviour the - test suite wants to observe. - """ - - @app.get("/login", name="login") - async def _login_placeholder() -> dict[str, bool]: - """Return a placeholder payload so ``request.url_for('login')`` resolves. - - :return: A fixed success payload. - """ - return {"ok": True} - - def _mount_plugin_router(plugin_router: APIRouter, plugin_prefix: str) -> FastAPI: """Mount ``plugin_router`` under the production-shape router tree. @@ -240,7 +222,6 @@ def _mount_plugin_router(plugin_router: APIRouter, plugin_prefix: str) -> FastAP api_router.include_router(apps_router) app = FastAPI() app.include_router(api_router) - _register_login_placeholder(app) return app @@ -261,7 +242,7 @@ def _build_composed_app(schema: AppSchema, plugin_prefix: str) -> FastAPI: def authed_client(regular_user: CasdoorUser) -> TestClient: """Yield an authed ``TestClient`` over a production-shape composed app.""" app = _build_composed_app(_TEST_SCHEMA, "/test-schema-endpoint") - app.dependency_overrides[get_api_authenticated_user] = lambda: regular_user + app.dependency_overrides[get_current_user] = lambda: regular_user return TestClient(app, raise_server_exceptions=False) @@ -269,7 +250,7 @@ def authed_client(regular_user: CasdoorUser) -> TestClient: def authed_all_fields_client(regular_user: CasdoorUser) -> TestClient: """Yield an authed ``TestClient`` whose schema exercises every field class.""" app = _build_composed_app(_ALL_FIELDS_SCHEMA, "/test-all-fields") - app.dependency_overrides[get_api_authenticated_user] = lambda: regular_user + app.dependency_overrides[get_current_user] = lambda: regular_user return TestClient(app, raise_server_exceptions=False) @@ -277,7 +258,7 @@ def authed_all_fields_client(regular_user: CasdoorUser) -> TestClient: def authed_empty_forms_client(regular_user: CasdoorUser) -> TestClient: """Yield an authed ``TestClient`` whose schema has zero form sections.""" app = _build_composed_app(_EMPTY_FORMS_SCHEMA, "/test-empty-forms") - app.dependency_overrides[get_api_authenticated_user] = lambda: regular_user + app.dependency_overrides[get_current_user] = lambda: regular_user return TestClient(app, raise_server_exceptions=False) @@ -309,7 +290,7 @@ def test_route_declares_api_authenticated(self) -> None: [route] = [r for r in router.routes if isinstance(r, APIRoute)] callables = {d.dependency for d in route.dependencies} - assert get_api_authenticated_user in callables + assert get_current_user in callables def test_route_declares_response_model(self) -> None: """Assert the route wires ``response_model=AppSchema`` for OpenAPI.""" @@ -359,7 +340,6 @@ def test_naked_app_unauth_returns_401(self) -> None: schema_endpoint(plugin_router, _TEST_SCHEMA) app = FastAPI() app.include_router(plugin_router) - _register_login_placeholder(app) response = TestClient(app, raise_server_exceptions=False).get( "/schema", follow_redirects=False @@ -374,7 +354,7 @@ def test_duplicate_dep_runs_once_per_request( Compose the production-shape router tree with ``IsApiAuthenticated`` declared at both router level and (via the helper) route level, then - spy on ``get_api_authenticated_user`` and confirm one authed request + spy on ``get_current_user`` and confirm one authed request invokes it exactly once — the behaviour that makes the belt-and- braces deviation free. """ @@ -385,7 +365,7 @@ def spy() -> CasdoorUser: return regular_user app = _build_composed_app(_TEST_SCHEMA, "/test-schema-endpoint") - app.dependency_overrides[get_api_authenticated_user] = spy + app.dependency_overrides[get_current_user] = spy client = TestClient(app, raise_server_exceptions=False) response = client.get("/api/apps/test-schema-endpoint/schema") @@ -559,7 +539,7 @@ def test_all_fields_schema_reparses_losslessly( def authed_conditional_rules_client(regular_user: CasdoorUser) -> TestClient: """Yield an authed client whose schema exercises the new rule primitives.""" app = _build_composed_app(_CONDITIONAL_RULES_SCHEMA, "/test-conditional-rules") - app.dependency_overrides[get_api_authenticated_user] = lambda: regular_user + app.dependency_overrides[get_current_user] = lambda: regular_user return TestClient(app, raise_server_exceptions=False) @@ -662,7 +642,7 @@ def test_no_new_primitive_keys_when_unused( def authed_derived_client(regular_user: CasdoorUser) -> TestClient: """Yield an authed client whose schema exercises the ``derived`` field.""" app = _build_composed_app(_DERIVED_SCHEMA, "/test-derived") - app.dependency_overrides[get_api_authenticated_user] = lambda: regular_user + app.dependency_overrides[get_current_user] = lambda: regular_user return TestClient(app, raise_server_exceptions=False) @@ -753,7 +733,7 @@ def provider() -> _DummyCapabilities: if isinstance(r, APIRoute) and r.path == "/capabilities" ] callables = {d.dependency for d in route.dependencies} - assert get_api_authenticated_user in callables + assert get_current_user in callables def test_response_model_inferred_from_return_annotation(self) -> None: """Assert the route's ``response_model`` matches the provider's return annotation.""" @@ -932,7 +912,7 @@ def authed_capabilities_client(self, regular_user: CasdoorUser) -> TestClient: """Yield an authed ``TestClient`` over the capabilities-app.""" _provider_state["flag"] = True app = _build_capabilities_app(_stateful_provider) - app.dependency_overrides[get_api_authenticated_user] = lambda: regular_user + app.dependency_overrides[get_current_user] = lambda: regular_user return TestClient(app, raise_server_exceptions=False) def test_authed_get_returns_provider_payload( @@ -1009,7 +989,6 @@ def test_naked_app_unauth_returns_401(self) -> None: capabilities_endpoint(plugin_router, capabilities_provider=_stateful_provider) app = FastAPI() app.include_router(plugin_router) - _register_login_placeholder(app) response = TestClient(app, raise_server_exceptions=False).get( "/capabilities", follow_redirects=False @@ -1032,7 +1011,7 @@ def test_openapi_documents_capabilities_response( produce an untyped client. """ app = _build_capabilities_app(_stateful_provider) - app.dependency_overrides[get_api_authenticated_user] = lambda: regular_user + app.dependency_overrides[get_current_user] = lambda: regular_user client = TestClient(app, raise_server_exceptions=False) openapi = client.get("/openapi.json").json() @@ -1062,7 +1041,7 @@ def provider() -> _DummyCapabilities: raise RuntimeError("provider failed") app = _build_capabilities_app(provider) - app.dependency_overrides[get_api_authenticated_user] = lambda: regular_user + app.dependency_overrides[get_current_user] = lambda: regular_user client = TestClient(app, raise_server_exceptions=False) response = client.get("/api/apps/test-capabilities-endpoint/capabilities") @@ -1090,7 +1069,7 @@ def provider( return _DummyCapabilities(flag=flag) app = _build_capabilities_app(provider) - app.dependency_overrides[get_api_authenticated_user] = lambda: regular_user + app.dependency_overrides[get_current_user] = lambda: regular_user client = TestClient(app, raise_server_exceptions=False) response = client.get("/api/apps/test-capabilities-endpoint/capabilities") @@ -1393,7 +1372,7 @@ def _authed_crud_client( ) -> TestClient: """Mount ``router`` in a production-shape app with auth + Tasks-API overrides.""" app = _mount_plugin_router(router, _CRUD_PREFIX) - app.dependency_overrides[get_api_authenticated_user] = lambda: user + app.dependency_overrides[get_current_user] = lambda: user app.dependency_overrides[get_tasks_api] = lambda: tasks_api return TestClient(app, raise_server_exceptions=False) @@ -1409,7 +1388,7 @@ def _authed_script_client(router: APIRouter, user: CasdoorUser) -> TestClient: :return: A bare ``TestClient`` mounting the script router under the script prefix. """ app = _mount_plugin_router(router, _SCRIPT_PREFIX) - app.dependency_overrides[get_api_authenticated_user] = lambda: user + app.dependency_overrides[get_current_user] = lambda: user return TestClient(app, raise_server_exceptions=False) @@ -1496,7 +1475,7 @@ def test_derived_routes_declare_api_authenticated(self) -> None: for route in _api_routes(router): callables = {d.dependency for d in route.dependencies} - assert get_api_authenticated_user in callables, route.path + assert get_current_user in callables, route.path def test_derived_routes_emit_by_alias(self) -> None: """Assert list / detail / create pin ``response_model_by_alias=True``.""" @@ -2761,7 +2740,7 @@ def test_create_route_keeps_api_authenticated_with_extra_dep(self) -> None: route = _route_for(router, "/", "POST") callables = {dep.dependency for dep in route.dependencies} - assert get_api_authenticated_user in callables + assert get_current_user in callables assert _marker_dep in callables @@ -2851,7 +2830,7 @@ def _authed_execute_client( ) -> TestClient: """Mount ``router`` in a production-shape app with auth + Tasks-API overrides.""" app = _mount_plugin_router(router, _EXECUTE_PREFIX) - app.dependency_overrides[get_api_authenticated_user] = lambda: user + app.dependency_overrides[get_current_user] = lambda: user app.dependency_overrides[get_tasks_api] = lambda: tasks_api return TestClient(app, raise_server_exceptions=False) @@ -2893,7 +2872,7 @@ def test_route_declares_standard_guards(self) -> None: route = _route_for(_execute_router(), "/{task_name}/execute", "POST") callables = {d.dependency for d in route.dependencies} - assert get_api_authenticated_user in callables + assert get_current_user in callables assert check_for_conflicted_running_tasks in callables def test_extra_deps_appended_to_standard_guards(self) -> None: @@ -2902,7 +2881,7 @@ def test_extra_deps_appended_to_standard_guards(self) -> None: route = _route_for(router, "/{task_name}/execute", "POST") callables = {d.dependency for d in route.dependencies} - assert get_api_authenticated_user in callables + assert get_current_user in callables assert check_for_conflicted_running_tasks in callables assert _marker_dep in callables @@ -3165,7 +3144,7 @@ def _authed_cascade_client( ) -> TestClient: """Mount ``router`` in a production-shape app with auth + Tasks-API overrides.""" app = _mount_plugin_router(router, _CASCADE_PREFIX) - app.dependency_overrides[get_api_authenticated_user] = lambda: user + app.dependency_overrides[get_current_user] = lambda: user app.dependency_overrides[get_tasks_api] = lambda: tasks_api return TestClient(app, raise_server_exceptions=False) diff --git a/tests/app/sep/apps/framework/test_apps.py b/tests/app/sep/apps/framework/test_apps.py index 677f4a31fb..572d9d51d4 100644 --- a/tests/app/sep/apps/framework/test_apps.py +++ b/tests/app/sep/apps/framework/test_apps.py @@ -23,7 +23,6 @@ """ from dataclasses import replace -from pathlib import Path from typing import Annotated, Any, get_args from unittest.mock import AsyncMock @@ -43,7 +42,6 @@ from app.sep.apps.framework import ( BaseTaskResponse, ConnectivityWarning, - StaticMount, TaskExecuteWrite, TaskExecutionResponse, ) @@ -1728,12 +1726,3 @@ class TestInheritedBaseAppFields: def test_defaults_uses_task_data_true(self) -> None: """Carry ``True`` by default: a derived task app always renders task data.""" assert _synth_app().uses_task_data is True - - def test_static_mounts_round_trip_after_inheriting(self) -> None: - """Accept and carry ``static_mounts`` now that the field lives on the parent.""" - mount = StaticMount( - path="/static/synthetic", - directory=Path("/tmp/payloads"), - name="synthetic_files", - ) - assert _synth_app(static_mounts=(mount,)).static_mounts == (mount,) diff --git a/tests/app/sep/apps/framework/test_base.py b/tests/app/sep/apps/framework/test_base.py index 89d761fbb9..83bc217563 100644 --- a/tests/app/sep/apps/framework/test_base.py +++ b/tests/app/sep/apps/framework/test_base.py @@ -21,7 +21,7 @@ from sqlalchemy_celery_beat.models import Period from app.core.celery.models import IntervalSchedule -from app.sep.apps.framework.base import AppPeriodicTask, BaseApp, StaticMount +from app.sep.apps.framework.base import AppPeriodicTask, BaseApp class TestBaseAppDisplayName: @@ -51,21 +51,17 @@ class TestBaseAppArbitraryTypes: def test_accepts_live_api_router(self) -> None: """A live ``APIRouter`` is accepted on the router fields.""" api_router = APIRouter() - jinja_router = APIRouter() app = BaseApp( name="Checksums", uri_path="/checksums", api_router=api_router, - jinja_router=jinja_router, ) assert app.api_router is api_router - assert app.jinja_router is jinja_router def test_router_fields_default_to_none(self) -> None: """Both router fields default to ``None`` when unset.""" app = BaseApp(name="Inventory", uri_path="/inventory") assert app.api_router is None - assert app.jinja_router is None class TestBaseAppSchemaAlias: @@ -160,25 +156,6 @@ def _factory() -> list[AppPeriodicTask]: assert app.periodic_task_schedules() == specs -class TestBaseAppStaticMounts: - """Cover the ``static_mounts`` registry-collected authenticated mounts.""" - - def test_static_mounts_defaults_to_empty(self) -> None: - """Return an empty tuple when the field is unset.""" - app = BaseApp(name="Inventory", uri_path="/inventory") - assert app.static_mounts == () - - def test_static_mounts_carries_declaration(self) -> None: - """Carry a declared mount on a plain ``BaseApp``.""" - mount = StaticMount( - path="/static/dipper", - directory=Path("/tmp/payloads"), - name="dipper_files", - ) - app = BaseApp(name="Dipper", uri_path="/dipper", static_mounts=(mount,)) - assert app.static_mounts == (mount,) - - class TestBaseAppUsesTaskData: """Cover the ``uses_task_data`` shared task-route opt-in.""" diff --git a/tests/app/sep/apps/framework/test_connectivity.py b/tests/app/sep/apps/framework/test_connectivity.py index 0c6248fc0e..310ffba215 100644 --- a/tests/app/sep/apps/framework/test_connectivity.py +++ b/tests/app/sep/apps/framework/test_connectivity.py @@ -28,7 +28,6 @@ ) from app.sep.connectivity import ( _record_latest_result, - annotate_tasks_with_connectivity, clear_connectivity_caches, get_latest_connectivity_result, ) @@ -209,35 +208,6 @@ async def test_writes_to_latest_results_on_failure(self, mock_tasks_api): assert get_latest_connectivity_result("node1", "mysql") is False - @pytest.mark.asyncio - async def test_snapshot_drives_list_view_annotation(self, mock_tasks_api): - """Verify ``annotate_tasks_with_connectivity`` reflects the JSON-path result.""" - mock_tasks_api.post.return_value = { - "success": False, - "error": "connection refused", - } - await record_connectivity_warning( - mock_tasks_api, - target="node1", - host="10.0.0.1", - port=3306, - service_type="mysql", - ) - - tasks = [ - { - "data": { - "meta": { - "target": "node1", - "_connectivity_service_type": ServiceTypeEnum.MYSQL.value, - }, - }, - }, - ] - annotate_tasks_with_connectivity(tasks) - - assert tasks[0]["_connectivity_warning"] is True - class TestMaybeRecordConnectivityWarning: """Test the maybe_record_connectivity_warning guard helper.""" diff --git a/tests/app/sep/apps/framework/test_deprecation.py b/tests/app/sep/apps/framework/test_deprecation.py deleted file mode 100644 index f216f25aff..0000000000 --- a/tests/app/sep/apps/framework/test_deprecation.py +++ /dev/null @@ -1,115 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Tests for the framework's Jinja2 route deprecation marker.""" - -from unittest.mock import patch - -import pytest -from fastapi import APIRouter, FastAPI, status -from fastapi.responses import HTMLResponse, RedirectResponse -from fastapi.testclient import TestClient - -from app.sep.apps.framework.deprecation import DeprecatedJinja2Route - - -def _build_app() -> FastAPI: - router = APIRouter(route_class=DeprecatedJinja2Route) - - @router.get("/legacy") - def legacy_endpoint() -> dict: - return {"ok": True} - - @router.post("/legacy/action") - def legacy_action() -> dict: - return {"ok": True} - - @router.get("/legacy/html", response_class=HTMLResponse) - def legacy_html() -> HTMLResponse: - return HTMLResponse("

          hi

          ") - - @router.get("/legacy/redirect") - def legacy_redirect() -> RedirectResponse: - return RedirectResponse("/legacy", status_code=status.HTTP_303_SEE_OTHER) - - app = FastAPI() - app.include_router(router) - return app - - -def test_deprecation_header_set_on_dict_response(): - """Every GET response under the router carries Deprecation: true.""" - response = TestClient(_build_app()).get("/legacy") - - assert response.status_code == status.HTTP_200_OK - assert response.headers.get("Deprecation") == "true" - - -def test_deprecation_header_set_on_html_response(): - """Regression: header is set even when the endpoint returns HTMLResponse directly.""" - response = TestClient(_build_app()).get("/legacy/html") - - assert response.status_code == status.HTTP_200_OK - assert response.headers.get("Deprecation") == "true" - - -def test_deprecation_header_set_on_redirect_response(): - """Regression: header is set even when the endpoint returns RedirectResponse directly.""" - client = TestClient(_build_app(), follow_redirects=False) - - response = client.get("/legacy/redirect") - - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers.get("Deprecation") == "true" - - -def test_deprecation_header_set_on_post_response(): - """POST responses under the router also carry Deprecation: true.""" - response = TestClient(_build_app()).post("/legacy/action") - - assert response.status_code == status.HTTP_200_OK - assert response.headers.get("Deprecation") == "true" - - -def test_deprecation_emits_warning_log(): - """A WARNING log records the deprecated path on each hit.""" - with patch( - "app.sep.apps.framework.deprecation.logger.warning", - ) as mock_warning: - TestClient(_build_app()).get("/legacy") - - assert mock_warning.called - args = mock_warning.call_args.args - assert "Jinja2 plugin route /legacy is deprecated" in args[0] - - -def test_deprecation_emits_deprecationwarning(): - """Each deprecated route hit triggers a ``DeprecationWarning``.""" - with pytest.warns(DeprecationWarning, match="Jinja2 plugin route /legacy"): - response = TestClient(_build_app()).get("/legacy") - - assert response.status_code == status.HTTP_200_OK - assert response.headers.get("Deprecation") == "true" - - -def test_deprecated_routes_omitted_from_openapi(): - """Verify legacy Jinja routes using ``DeprecatedJinja2Route`` are omitted from the OpenAPI schema.""" - app = _build_app() - spec = app.openapi() - - for path in ("/legacy", "/legacy/action", "/legacy/html", "/legacy/redirect"): - assert path not in spec.get("paths", {}), ( - f"{path} should be omitted from OpenAPI schema" - ) diff --git a/tests/app/sep/apps/framework/test_index_context_service_type.py b/tests/app/sep/apps/framework/test_index_context_service_type.py deleted file mode 100644 index 20d20554bb..0000000000 --- a/tests/app/sep/apps/framework/test_index_context_service_type.py +++ /dev/null @@ -1,85 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Guard the per-app service-type wiring of every owning app's index context. - -``get_tasks_context`` no longer derives the inventory service type from the -owner; each ``get__index_context`` passes an explicit ``service_type`` -literal at the call site. This pins that literal per app, so the four apps whose -``TaskExecutionApp.service_type`` is ``None`` (they pass the literal directly) -cannot silently regress to an unscoped ``/services/`` fetch. -""" - -from unittest.mock import AsyncMock - -import pytest - -from app.inventory.models import ServiceTypeEnum -from app.sep.apps.alters.deps import get_alters_index_context -from app.sep.apps.archives.deps import get_archives_index_context -from app.sep.apps.backup_mongo.deps import ( - get_backups_index_context as get_backup_mongo_index_context, -) -from app.sep.apps.backup_mongo.restore.deps import ( - get_restores_index_context as get_restore_mongo_index_context, -) -from app.sep.apps.backup_pg.deps import ( - get_backups_index_context as get_backup_pg_index_context, -) -from app.sep.apps.checksums.deps import get_checksums_index_context -from app.sep.apps.mysql_backups.deps import ( - get_backups_index_context as get_mysql_backups_index_context, -) -from app.sep.apps.mysql_backups.restore.deps import ( - get_restores_index_context as get_mysql_restores_index_context, -) -from app.sep.deps import ExecutorHostsContext - -_EMPTY_PAGE = {"items": [], "total": 0, "offset": 0, "limit": 50} - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("index_context", "expected_service_type"), - [ - (get_alters_index_context, ServiceTypeEnum.MYSQL), - (get_archives_index_context, ServiceTypeEnum.MYSQL), - (get_backup_mongo_index_context, ServiceTypeEnum.MONGODB), - (get_restore_mongo_index_context, ServiceTypeEnum.MONGODB), - (get_backup_pg_index_context, ServiceTypeEnum.POSTGRESQL), - (get_checksums_index_context, ServiceTypeEnum.MYSQL), - (get_mysql_backups_index_context, ServiceTypeEnum.MYSQL), - (get_mysql_restores_index_context, ServiceTypeEnum.MYSQL), - ], -) -async def test_index_context_scopes_services_fetch_by_service_type( - index_context, expected_service_type -) -> None: - """Drive each app's index context and assert its ``/services/`` fetch scope.""" - inventory_api = AsyncMock() - inventory_api.get = AsyncMock(return_value=_EMPTY_PAGE) - tasks_api = AsyncMock() - tasks_api.get = AsyncMock(return_value=_EMPTY_PAGE) - executor_hosts_ctx = ExecutorHostsContext(hosts={}, display_names={}) - - await index_context(inventory_api, tasks_api, {}, executor_hosts_ctx) - - services_calls = [ - call - for call in inventory_api.get.await_args_list - if call.args and call.args[0] == "/services/" - ] - assert services_calls, "index context never fetched /services/" - assert services_calls[0].kwargs["params"]["service_type"] == expected_service_type diff --git a/tests/app/sep/apps/framework/test_registry.py b/tests/app/sep/apps/framework/test_registry.py index 6d6713018b..c72abe1cf8 100644 --- a/tests/app/sep/apps/framework/test_registry.py +++ b/tests/app/sep/apps/framework/test_registry.py @@ -94,11 +94,10 @@ def test_legacy_plugin_wrapped_with_metadata(self) -> None: assert app.custom_ui is False assert app.app_schema is None - def test_legacy_plugin_resolves_both_routers(self) -> None: - """The synthesized app carries the resolved Jinja and API routers.""" + def test_legacy_plugin_resolves_api_router(self) -> None: + """Carry the resolved API router on the synthesized app.""" registry = build_app_registry([App(name="Snippets", module_name="snippets")]) app = registry.get("snippets") - assert isinstance(app.jinja_router, APIRouter) assert isinstance(app.api_router, APIRouter) def test_module_name_only_derives_metadata(self, mocker: MockerFixture) -> None: @@ -513,12 +512,10 @@ def test_alters_binds_as_task_execution_app_with_nav_metadata(self) -> None: assert app.nav_order == definition.nav_order api_routes = importlib.import_module("app.sep.apps.alters.api_routes") - routes = importlib.import_module("app.sep.apps.alters.routes") schema = importlib.import_module("app.sep.apps.alters.schema") assert app.app_schema is schema.alters_schema assert api_routes.router in app.extra_routes assert app.api_router is not api_routes.router - assert app.jinja_router is routes.router BESPOKE_BASE_APP_PLUGINS = [ @@ -543,12 +540,10 @@ def test_module_exports_bare_base_app(self, plugin: str) -> None: @pytest.mark.parametrize("plugin", BESPOKE_BASE_APP_PLUGINS) def test_registry_binds_definition_routers(self, plugin: str) -> None: - """Bind each plugin's API and Jinja routers by identity through the registry.""" + """Bind each plugin's API router by identity through the registry.""" api_routes = importlib.import_module(f"app.sep.apps.{plugin}.api_routes") - routes = importlib.import_module(f"app.sep.apps.{plugin}.routes") app = get_app_registry().get(plugin) assert app.api_router is api_routes.router - assert app.jinja_router is routes.router def test_inventory_definition_carries_schema(self) -> None: """Carry ``inventory_schema`` on the inventory definition's ``app_schema``.""" @@ -576,20 +571,14 @@ def test_snippet_consumers_declare_no_required_apps(self, plugin: str) -> None: assert get_app_registry().get(plugin).requires_apps == () @pytest.mark.parametrize("plugin", BESPOKE_BASE_APP_PLUGINS) - def test_legacy_router_reexport_preserved(self, plugin: str) -> None: - """Preserve the legacy Jinja ``router`` re-export on each package.""" + def test_legacy_router_reexport_removed(self, plugin: str) -> None: + """Assert no package still re-exports a ``router`` from the deleted Jinja layer.""" package = importlib.import_module(f"app.sep.apps.{plugin}") - routes = importlib.import_module(f"app.sep.apps.{plugin}.routes") - assert package.router is routes.router + assert not hasattr(package, "router") class TestAtwDefinition: - """Cover the atw ``BaseApp`` definition (no Jinja router, no legacy re-export). - - atw is kept out of ``BESPOKE_BASE_APP_PLUGINS`` because it ships no - ``routes.py`` Jinja router and no legacy ``router`` re-export, so the - parametrized router/re-export assertions there do not apply. - """ + """Cover the atw ``BaseApp`` definition.""" def test_module_exports_bare_base_app(self) -> None: """Assert atw exports a bare ``BaseApp``, not a ``TaskExecutionApp``.""" @@ -597,12 +586,11 @@ def test_module_exports_bare_base_app(self) -> None: assert isinstance(app, BaseApp) assert not isinstance(app, TaskExecutionApp) - def test_registry_binds_api_router_without_jinja(self) -> None: - """Bind atw's API router by identity and mount no Jinja router.""" + def test_registry_binds_api_router(self) -> None: + """Bind atw's API router by identity.""" api_routes = importlib.import_module("app.sep.apps.atw.api_routes") app = get_app_registry().get("atw") assert app.api_router is api_routes.router - assert app.jinja_router is None def test_definition_carries_schema_and_nav_metadata(self) -> None: """Carry ``atw_schema``, ``custom_ui``, and nav metadata from the definition.""" diff --git a/tests/app/sep/apps/framework/test_scaffold.py b/tests/app/sep/apps/framework/test_scaffold.py index 80e09527cc..c591163c1d 100644 --- a/tests/app/sep/apps/framework/test_scaffold.py +++ b/tests/app/sep/apps/framework/test_scaffold.py @@ -57,7 +57,7 @@ from app.sep.apps.framework.registry import build_app_registry from app.sep.apps.nav_icons import NavIcon from app.sep.config import App -from app.sep.deps import get_api_authenticated_user, IsApiAuthenticated +from app.sep.deps import get_current_user, IsApiAuthenticated from app.sep.snippets.config import snippets_settings from tests.app.sep.apps.framework.contract_suite import ( app_base_url, @@ -208,7 +208,7 @@ def _mount_api_first(app_def: BaseApp, user: CasdoorUser) -> TestClient: api_router.include_router(apps_router) fastapi_app = FastAPI() fastapi_app.include_router(api_router) - fastapi_app.dependency_overrides[get_api_authenticated_user] = lambda: user + fastapi_app.dependency_overrides[get_current_user] = lambda: user return TestClient(fastapi_app, raise_server_exceptions=False) @@ -606,7 +606,6 @@ def test_task_flavor_scaffolds_conformant_app( registry = build_app_registry([App(module_name=name)]) app = registry.get(name) assert isinstance(app, TaskExecutionApp) - assert app.jinja_router is None assert not _task_conformance(app) assert not check_route_collisions(registry) @@ -639,7 +638,6 @@ def test_script_flavor_scaffolds_snippet_routes( registry = build_app_registry([App(module_name=name)]) app = registry.get(name) assert isinstance(app, TaskExecutionApp) - assert app.jinja_router is None assert not _task_conformance(app) tasks_api = AsyncMock(spec=RemoteAPI) @@ -675,7 +673,6 @@ def test_base_flavor_scaffolds_api_first_app( registry = build_app_registry([App(module_name=name)]) app = registry.get(name) assert app is not None - assert app.jinja_router is None assert app.api_router is not None assert not check_route_collisions(registry) diff --git a/tests/app/sep/apps/framework/test_script_source.py b/tests/app/sep/apps/framework/test_script_source.py index b699b09eef..e7b0e08d66 100644 --- a/tests/app/sep/apps/framework/test_script_source.py +++ b/tests/app/sep/apps/framework/test_script_source.py @@ -37,7 +37,7 @@ from unittest.mock import AsyncMock import pytest -from fastapi import APIRouter, Depends, FastAPI, status +from fastapi import APIRouter, Depends, status from fastapi.testclient import TestClient from pydantic import BaseModel, create_model @@ -46,7 +46,7 @@ from app.core.pagination import Pagination from app.core.pagination.deps import make_pagination_dep from app.core.requests.remote_api import RemoteAPI -from app.sep.apps.framework import ScriptSource, StaticMount, TaskExecutionApp +from app.sep.apps.framework import ScriptSource, TaskExecutionApp from app.sep.apps.framework.apps import NO_PAGINATION from app.sep.apps.framework.conformance import ( check_capability_route_consistency, @@ -63,7 +63,6 @@ ) from app.sep.apps.framework.script_source import resolve_scripts, ScriptExecuteWrite from app.sep.deps import IsApiAuthenticated -from app.sep.utils.static import AuthenticatedStaticFiles from tests.app.sep.apps.framework.contract_suite import build_contract_client from tests.app.sep.apps.framework.contract_suite import routes_of as _routes @@ -890,40 +889,3 @@ def test_schema_derivation_check_skips_script_app( ) -> None: """Return no findings for a script app from the create-model derivation check.""" assert check_schema_derivation_succeeds(_script_app(source)) == [] - - -class TestStaticMount: - """Cover the authenticated static-mount knob (auth parity AC).""" - - def test_static_mounts_field_carries_declaration( - self, source: ScriptSource - ) -> None: - """Carry the declared static mounts on the app definition.""" - mount = StaticMount( - path="/static/fixture", directory=source.script_dir, name="fixture_files" - ) - app_def = _script_app(source, static_mounts=(mount,)) - assert app_def.static_mounts == (mount,) - - def test_static_mount_enforces_authentication(self, source: ScriptSource) -> None: - """Mount the payload dir behind ``AuthenticatedStaticFiles`` (no anon access).""" - mount = StaticMount( - path="/static/fixture", directory=source.script_dir, name="fixture_files" - ) - app = FastAPI() - for declared in _script_app(source, static_mounts=(mount,)).static_mounts: - app.mount( - declared.path, - AuthenticatedStaticFiles(directory=declared.directory), - name=declared.name, - ) - mounted = [ - route - for route in app.routes - if getattr(route, "path", "") == "/static/fixture" - ] - assert mounted - assert isinstance(mounted[0].app, AuthenticatedStaticFiles) - client = TestClient(app, raise_server_exceptions=False) - response = client.get("/static/fixture/report.sh") - assert response.status_code != status.HTTP_200_OK diff --git a/tests/app/sep/apps/inventory/conftest.py b/tests/app/sep/apps/inventory/conftest.py index 9bed4cc578..46e182be91 100644 --- a/tests/app/sep/apps/inventory/conftest.py +++ b/tests/app/sep/apps/inventory/conftest.py @@ -127,35 +127,18 @@ def mock_background_tasks() -> Iterator[MagicMock]: @pytest.fixture def mock_run_sync_funcs(mocker: MockerFixture) -> dict[str, AsyncMock]: - """Replace the ``run_*_sync`` symbols on the routes/api_routes modules. + """Replace the ``run_*_sync`` symbols on the ``api_routes`` module. The real background-task callables open database sessions and invoke ``syncer.api_auth(...)``, which the lightweight stub syncers cannot - satisfy. Patching them at both the Jinja2 ``routes`` and JSON-API - ``api_routes`` module level lets the real ``BackgroundTasks`` instance - schedule and execute the mocks immediately after the response, capturing - the args originally passed to ``add_task``. + satisfy. Patching them at the module level lets the real + ``BackgroundTasks`` instance schedule and execute the mocks immediately + after the response, capturing the args originally passed to ``add_task``. """ inventory_mock = AsyncMock() node_mock = AsyncMock() service_mock = AsyncMock() schema_mock = AsyncMock() - mocker.patch( - "app.sep.apps.inventory.routes.run_inventory_sync", - new=inventory_mock, - ) - mocker.patch( - "app.sep.apps.inventory.routes.run_node_sync", - new=node_mock, - ) - mocker.patch( - "app.sep.apps.inventory.routes.run_service_sync", - new=service_mock, - ) - mocker.patch( - "app.sep.apps.inventory.routes.run_schema_sync", - new=schema_mock, - ) mocker.patch( "app.sep.apps.inventory.api_routes.run_inventory_sync", new=inventory_mock, diff --git a/tests/app/sep/apps/inventory/test_routes.py b/tests/app/sep/apps/inventory/test_routes.py deleted file mode 100644 index bfb73896f7..0000000000 --- a/tests/app/sep/apps/inventory/test_routes.py +++ /dev/null @@ -1,1674 +0,0 @@ -# Copyright (C) 2026 Percona LLC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Define tests for the app.sep.apps.inventory.routes module.""" - -import json -import re -from unittest.mock import AsyncMock - -import pytest -from fastapi import HTTPException, status -from pydantic import SecretStr - -from app.core.config import settings -from app.core.pagination import MAX_PAGINATION_LIMIT -from app.core.requests import RemoteAPI -from app.core.security import crypto_serializer -from app.inventory.models import ServiceTypeEnum, SourceEnum -from app.sep.apps.inventory.constants import CONNECTABLE_SERVICE_TYPES -from app.sep.apps.inventory.deps import AvailableSyncer, get_syncers -from app.sep.deps import ( - AVAILABLE_TIMEZONES, - get_created_node, - get_created_schema, - get_created_service, - get_tasks_api, -) -from app.sep.inventory import CreatedNode, CreatedSchema, CreatedService, CreatedTable -from app.sep.main import sep_app, templates -from tests.app.factories import ( - CreatedNodeFactory, - CreatedSchemaFactory, - CreatedServiceFactory, - CreatedTableFactory, - MOCK_CREATED_SCHEMA_ID, - MOCK_CREATED_SERVICE_ID, - MOCK_CREATED_TABLE_ID, -) -from tests.app.sep.apps.inventory.conftest import ( - EXPECTED_STUB_COUNT, - MYSQL_STUB_NAME, - no_syncers, - PMM_STUB_NAME, - StubMySQLSyncer, - StubPMMSyncer, -) - -_UNSUPPORTED_SERVICE_TYPES = sorted( - set(ServiceTypeEnum) - CONNECTABLE_SERVICE_TYPES, key=str -) - - -@pytest.fixture -def created_node() -> CreatedNode: - """Return a fake created node.""" - return CreatedNodeFactory.build() - - -@pytest.fixture -def _mock_created_node_dep(created_node): - """Mock the CreatedNodeDep dependency.""" - sep_app.dependency_overrides[get_created_node] = lambda: created_node - yield - sep_app.dependency_overrides = {} - - -@pytest.fixture -def created_service(created_node) -> CreatedService: - """Return a fake created service.""" - created_service = CreatedServiceFactory.build() - created_service.node = created_node - return created_service - - -@pytest.fixture -def _mock_created_service_dep(created_service): - """Mock the CreatedService dependency.""" - sep_app.dependency_overrides[get_created_service] = lambda: created_service - yield - sep_app.dependency_overrides = {} - - -@pytest.fixture -def created_schema(created_service) -> CreatedSchema: - """Return a fake created Schema.""" - created_schema = CreatedSchemaFactory.build() - created_schema.service = created_service - return created_schema - - -@pytest.fixture -def _mock_created_schema_dep(created_schema): - """Mock the CreatedSchema dependency.""" - sep_app.dependency_overrides[get_created_schema] = lambda: created_schema - yield - sep_app.dependency_overrides = {} - - -@pytest.fixture -def created_table(created_schema) -> CreatedTable: - """Return a fake created Table.""" - created_table = CreatedTableFactory.build() - created_table.database = created_schema - return created_table - - -@pytest.mark.usefixtures("mock_sync_item_manager", "mock_get_username_mapping") -def test_node_list(test_client, mock_inventory_api_dep, mock_task_api_dep): - """Test listing nodes.""" - mock_inventory_api_dep.get.return_value = { - "items": [], - "total": 0, - "offset": 0, - "limit": 50, - } - mock_task_api_dep.get.return_value = [] - response = test_client.get("/inventory/") - assert response.status_code == status.HTTP_200_OK - assert response.headers["content-type"] == "text/html; charset=utf-8" - mock_inventory_api_dep.get.assert_any_await( - "/nodes/", params={"offset": 0, "limit": MAX_PAGINATION_LIMIT} - ) - mock_task_api_dep.get.assert_any_await("/inventory-sync/periodic/") - - -def test_jinja_routes_are_omitted_from_openapi(): - """Verify legacy inventory Jinja2 routes are excluded from the OpenAPI schema. - - The React control consumes the new JSON route at - ``POST /api/apps/inventory/sync/``; the Jinja2 trigger remains - functional but is hidden from OpenAPI now that the supported API - surface is JSON-only. - """ - openapi = sep_app.openapi() - assert "/inventory/sync/" not in openapi["paths"] - assert "/inventory/" not in openapi["paths"] - - -@pytest.mark.asyncio -async def test_sync_inventory(async_test_client, mock_syncers, mock_run_sync_funcs): - """Test syncing inventory without an explicit ``syncer`` form field.""" - response = await async_test_client.post("/inventory/sync/", follow_redirects=False) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"] == "/inventory/" - mock_run_sync_funcs["inventory"].assert_awaited_once() - args = mock_run_sync_funcs["inventory"].await_args.args - assert list(args[1:]) == mock_syncers - - -@pytest.mark.asyncio -async def test_sync_inventory_with_valid_syncer_param( - async_test_client, mock_syncers, mock_run_sync_funcs -): - """Sync only the named syncer when a valid ``syncer`` form field is present.""" - response = await async_test_client.post( - "/inventory/sync/", - data={"syncer": PMM_STUB_NAME}, - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - mock_run_sync_funcs["inventory"].assert_awaited_once() - args = mock_run_sync_funcs["inventory"].await_args.args - assert list(args[1:]) == [mock_syncers[0]] - - -@pytest.mark.asyncio -async def test_sync_inventory_with_empty_syncer_param_syncs_all( - async_test_client, mock_syncers, mock_run_sync_funcs -): - """Treat an empty ``syncer`` form field as a sync-all request.""" - response = await async_test_client.post( - "/inventory/sync/", - data={"syncer": ""}, - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - mock_run_sync_funcs["inventory"].assert_awaited_once() - args = mock_run_sync_funcs["inventory"].await_args.args - assert list(args[1:]) == mock_syncers - - -@pytest.mark.asyncio -async def test_sync_inventory_with_unknown_syncer_param_skips_run( - async_test_client, mock_syncers, mock_run_sync_funcs -): - """Reject a POST whose ``syncer`` value matches no configured syncer. - - The route catches the ``ValueError`` raised by ``filter_syncers_by_name`` - and re-raises it as ``HTTPBadRequestException``, which SEP's global - ``HTTPException`` handler then converts into a 303 redirect with a flash - message, so the assertion focuses on the side effect that matters: the - background task must not have been scheduled. - """ - response = await async_test_client.post( - "/inventory/sync/", - data={"syncer": "not.a.real.Syncer"}, - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - mock_run_sync_funcs["inventory"].assert_not_awaited() - - -@pytest.mark.asyncio -async def test_sync_inventory_with_incapable_syncer_skips_run( - async_test_client, mocker, mock_run_sync_funcs -): - """Reject a POST whose syncer matches by name but cannot sync inventory.""" - incapable = StubPMMSyncer() - mocker.patch.object(incapable, "can_sync_inventory", return_value=False) - sep_app.dependency_overrides[get_syncers] = lambda: [incapable] - try: - response = await async_test_client.post( - "/inventory/sync/", - data={"syncer": PMM_STUB_NAME}, - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - mock_run_sync_funcs["inventory"].assert_not_awaited() - finally: - sep_app.dependency_overrides = {} - - -@pytest.mark.usefixtures( - "_mock_created_node_dep", "mock_sync_item_manager", "mock_get_username_mapping" -) -def test_node_detail( - test_client, - created_node, -): - """Test retrieving a node's detail page.""" - response = test_client.get(f"/inventory/{created_node.id}") - assert response.status_code == status.HTTP_200_OK - assert response.headers["content-type"] == "text/html; charset=utf-8" - assert created_node.name in response.text - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("_mock_created_node_dep") -async def test_sync_node( - async_test_client, created_node, mock_syncers, mock_run_sync_funcs -): - """Sync-all path: every configured syncer is forwarded in declaration order.""" - response = await async_test_client.post( - f"/inventory/{created_node.id}/sync/", follow_redirects=False - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"] == f"/inventory/{created_node.id}" - mock_run_sync_funcs["node"].assert_awaited_once() - args = mock_run_sync_funcs["node"].await_args.args - assert list(args[2:]) == mock_syncers - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("_mock_created_node_dep") -async def test_sync_node_with_valid_syncer_param( - async_test_client, created_node, mock_syncers, mock_run_sync_funcs -): - """Sync only the named syncer for a node when ``syncer`` is supplied.""" - response = await async_test_client.post( - f"/inventory/{created_node.id}/sync/", - data={"syncer": MYSQL_STUB_NAME}, - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - mock_run_sync_funcs["node"].assert_awaited_once() - args = mock_run_sync_funcs["node"].await_args.args - assert list(args[2:]) == [mock_syncers[1]] - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("_mock_created_node_dep") -async def test_sync_node_with_unknown_syncer_param_skips_run( - async_test_client, created_node, mock_syncers, mock_run_sync_funcs -): - """Reject a node sync POST whose syncer name matches no configured syncer.""" - response = await async_test_client.post( - f"/inventory/{created_node.id}/sync/", - data={"syncer": "not.a.real.Syncer"}, - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - mock_run_sync_funcs["node"].assert_not_awaited() - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("_mock_created_node_dep") -async def test_sync_node_without_syncer_param_passes_all_configured_syncers_even_if_one_currently_cannot_sync( - async_test_client, created_node, mocker, mock_run_sync_funcs -): - """Pin the refresh-and-chain contract for sync-all node syncs. - - The configured MySQL-style syncer reports it cannot currently sync the - node (e.g. because it has not yet been turned into a MySQL host by an - earlier syncer), but the sync-all path must still pass it through. The - runtime ``run_node_sync`` chain calls each syncer with - ``refresh_at_start=True`` from the second iteration onward, so a syncer - whose capability check is currently ``False`` may still become capable - once an earlier syncer mutates the node. - """ - pmm_stub = StubPMMSyncer() - mysql_stub = StubMySQLSyncer() - mocker.patch.object(mysql_stub, "can_sync_node", return_value=False) - sep_app.dependency_overrides[get_syncers] = lambda: [pmm_stub, mysql_stub] - try: - response = await async_test_client.post( - f"/inventory/{created_node.id}/sync/", - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - mock_run_sync_funcs["node"].assert_awaited_once() - args = mock_run_sync_funcs["node"].await_args.args - assert list(args[2:]) == [pmm_stub, mysql_stub] - finally: - sep_app.dependency_overrides = {} - - -def test_node_create(test_client, mock_inventory_api_dep): - """Test creating a new node.""" - form_data = { - "address": "127.0.0.1", - "name": "node_name", - "external_id": "", - "source": SourceEnum.PMM, - } - response = test_client.post("/inventory/", data=form_data, follow_redirects=False) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"] == "/inventory/" - - -def test_node_delete(test_client, created_node, mock_inventory_api_dep): - """Test deleting a node.""" - mock_inventory_api_dep.delete.return_value = AsyncMock() - response = test_client.post( - f"/inventory/{created_node.id}/delete", follow_redirects=False - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"] == "/inventory/" - - -@pytest.mark.usefixtures( - "_mock_created_service_dep", "mock_sync_item_manager", "mock_get_username_mapping" -) -def test_service_detail( - test_client, - created_service, -): - """Test retrieving a service's detail page.""" - response = test_client.get(f"/inventory/services/{created_service.id}") - assert response.status_code == status.HTTP_200_OK - assert response.headers["content-type"] == "text/html; charset=utf-8" - assert created_service.name in response.text - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("_mock_created_service_dep") -async def test_sync_service( - async_test_client, created_service, mock_syncers, mock_run_sync_funcs -): - """Sync-all path: every configured syncer is forwarded for service sync.""" - response = await async_test_client.post( - f"/inventory/services/{created_service.id}/sync/", follow_redirects=False - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"] == f"/inventory/services/{created_service.id}" - mock_run_sync_funcs["service"].assert_awaited_once() - args = mock_run_sync_funcs["service"].await_args.args - assert list(args[2:]) == mock_syncers - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("_mock_created_service_dep") -async def test_sync_service_with_valid_syncer_param( - async_test_client, created_service, mock_syncers, mock_run_sync_funcs -): - """Sync only the named syncer for a service when ``syncer`` is supplied.""" - response = await async_test_client.post( - f"/inventory/services/{created_service.id}/sync/", - data={"syncer": PMM_STUB_NAME}, - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - mock_run_sync_funcs["service"].assert_awaited_once() - args = mock_run_sync_funcs["service"].await_args.args - assert list(args[2:]) == [mock_syncers[0]] - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("_mock_created_service_dep") -async def test_sync_service_with_unknown_syncer_param_skips_run( - async_test_client, created_service, mock_syncers, mock_run_sync_funcs -): - """Reject a service sync POST with an unknown syncer name.""" - response = await async_test_client.post( - f"/inventory/services/{created_service.id}/sync/", - data={"syncer": "not.a.real.Syncer"}, - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - mock_run_sync_funcs["service"].assert_not_awaited() - - -@pytest.mark.usefixtures("_mock_created_node_dep") -def test_service_create_for_node(test_client, created_node, mock_inventory_api_dep): - """Test creating a new service for node.""" - form_data = { - "environment": "staging", - "external_id": "", - "name": "service_name", - "port": "3306", - "type": ServiceTypeEnum.MYSQL, - } - response = test_client.post( - f"/inventory/{created_node.id}/services/", - data=form_data, - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"] == f"/inventory/{created_node.id}" - - -def test_service_delete(test_client, created_service, mock_inventory_api_dep): - """Test deleting a service.""" - mock_inventory_api_dep.get.return_value = created_service.model_dump() - mock_inventory_api_dep.delete.return_value = {"node_id": created_service.node_id} - response = test_client.post( - f"/inventory/services/{created_service.id}/delete", follow_redirects=False - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"] == f"/inventory/{created_service.node_id}" - - -@pytest.mark.usefixtures( - "_mock_created_schema_dep", "mock_sync_item_manager", "mock_get_username_mapping" -) -def test_schema_detail( - test_client, - created_schema, -): - """Test retrieving a schema's detail page.""" - response = test_client.get(f"/inventory/schemas/{created_schema.id}") - assert response.status_code == status.HTTP_200_OK - assert response.headers["content-type"] == "text/html; charset=utf-8" - assert created_schema.name in response.text - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("_mock_created_schema_dep") -async def test_sync_schema( - async_test_client, created_schema, mock_syncers, mock_run_sync_funcs -): - """Sync-all path: every configured syncer is forwarded for schema sync.""" - response = await async_test_client.post( - f"/inventory/schemas/{created_schema.id}/sync/", follow_redirects=False - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"] == f"/inventory/schemas/{created_schema.id}" - mock_run_sync_funcs["schema"].assert_awaited_once() - args = mock_run_sync_funcs["schema"].await_args.args - assert list(args[2:]) == mock_syncers - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("_mock_created_schema_dep") -async def test_sync_schema_with_valid_syncer_param( - async_test_client, created_schema, mock_syncers, mock_run_sync_funcs -): - """Sync only the named syncer for a schema when ``syncer`` is supplied.""" - response = await async_test_client.post( - f"/inventory/schemas/{created_schema.id}/sync/", - data={"syncer": MYSQL_STUB_NAME}, - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - mock_run_sync_funcs["schema"].assert_awaited_once() - args = mock_run_sync_funcs["schema"].await_args.args - assert list(args[2:]) == [mock_syncers[1]] - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("_mock_created_schema_dep") -async def test_sync_schema_with_unknown_syncer_param_skips_run( - async_test_client, created_schema, mock_syncers, mock_run_sync_funcs -): - """Reject a schema sync POST with an unknown syncer name.""" - response = await async_test_client.post( - f"/inventory/schemas/{created_schema.id}/sync/", - data={"syncer": "not.a.real.Syncer"}, - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - mock_run_sync_funcs["schema"].assert_not_awaited() - - -@pytest.mark.asyncio -async def test_sync_inventory_uses_internal_token( - async_test_client, mock_syncers, mock_run_sync_funcs, mocker -): - """The inventory sync background task is scheduled with the internal token.""" - mocker.patch.object( - settings, "SEP_INTERNAL_TOKEN", SecretStr("route-internal-token") - ) - response = await async_test_client.post("/inventory/sync/", follow_redirects=False) - assert response.status_code == status.HTTP_303_SEE_OTHER - mock_run_sync_funcs["inventory"].assert_awaited_once() - assert mock_run_sync_funcs["inventory"].await_args.args[0] == "route-internal-token" - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("_mock_created_node_dep") -async def test_sync_node_uses_internal_token( - async_test_client, created_node, mock_syncers, mock_run_sync_funcs, mocker -): - """The node sync background task is scheduled with the internal token.""" - mocker.patch.object( - settings, "SEP_INTERNAL_TOKEN", SecretStr("route-internal-token") - ) - response = await async_test_client.post( - f"/inventory/{created_node.id}/sync/", follow_redirects=False - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - mock_run_sync_funcs["node"].assert_awaited_once() - assert mock_run_sync_funcs["node"].await_args.args[1] == "route-internal-token" - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("_mock_created_service_dep") -async def test_sync_service_uses_internal_token( - async_test_client, created_service, mock_syncers, mock_run_sync_funcs, mocker -): - """The service sync background task is scheduled with the internal token.""" - mocker.patch.object( - settings, "SEP_INTERNAL_TOKEN", SecretStr("route-internal-token") - ) - response = await async_test_client.post( - f"/inventory/services/{created_service.id}/sync/", follow_redirects=False - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - mock_run_sync_funcs["service"].assert_awaited_once() - assert mock_run_sync_funcs["service"].await_args.args[1] == "route-internal-token" - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("_mock_created_schema_dep") -async def test_sync_schema_uses_internal_token( - async_test_client, created_schema, mock_syncers, mock_run_sync_funcs, mocker -): - """The schema sync background task is scheduled with the internal token.""" - mocker.patch.object( - settings, "SEP_INTERNAL_TOKEN", SecretStr("route-internal-token") - ) - response = await async_test_client.post( - f"/inventory/schemas/{created_schema.id}/sync/", follow_redirects=False - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - mock_run_sync_funcs["schema"].assert_awaited_once() - assert mock_run_sync_funcs["schema"].await_args.args[1] == "route-internal-token" - - -def test_schema_create_for_service( - test_client, created_service, mock_inventory_api_dep -): - """Test creating a new schema for service.""" - form_data = {"name": "schema_name"} - response = test_client.post( - f"/inventory/services/{created_service.id}/schemas/", - data=form_data, - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"] == f"/inventory/services/{created_service.id}" - - -def test_schema_delete(test_client, created_schema, mock_inventory_api_dep): - """Test deleting a schema.""" - returned_service_id = MOCK_CREATED_SERVICE_ID - mock_inventory_api_dep.get.return_value = created_schema.model_dump() - mock_inventory_api_dep.delete.return_value = {"service_id": returned_service_id} - response = test_client.post( - f"/inventory/schemas/{created_schema.id}/delete", follow_redirects=False - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"] == f"/inventory/services/{returned_service_id}" - - -def test_table_create_for_schema(test_client, created_schema, mock_inventory_api_dep): - """Test creating a new table for schema.""" - form_data = { - "name": "table_name", - "create": """CREATE TABLE employees ( - id INT PRIMARY KEY AUTO_INCREMENT, - name VARCHAR(50), - email VARCHAR(100) - );""", - } - response = test_client.post( - f"/inventory/schemas/{created_schema.id}/tables/", - data=form_data, - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - - -def test_table_delete(test_client, created_table, mock_inventory_api_dep): - """Test deleting a table.""" - delete_table_id = MOCK_CREATED_TABLE_ID - returned_schema_id = MOCK_CREATED_SCHEMA_ID - mock_inventory_api_dep.get.return_value = created_table.model_dump() - mock_inventory_api_dep.delete.return_value = {"schema_id": returned_schema_id} - response = test_client.post( - f"/inventory/tables/{delete_table_id}/delete", follow_redirects=False - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"] == f"/inventory/schemas/{returned_schema_id}" - - -def _flashed_texts(response) -> list[str]: - """Return the flash-message texts the response serialised into the cookie. - - The messages middleware round-trips the queue through a signed ``messages`` - cookie, so this is the only place a redirect's user-visible text is - observable from a test. - """ - cookie = response.cookies.get("messages") - if cookie is None: - return [] - return [message["t"] for message in json.loads(crypto_serializer.loads(cookie))] - - -class TestCheckServiceConnectivity: - """Test the check_service_connectivity route.""" - - @pytest.fixture - def mock_tasks_api_dep(self) -> AsyncMock: - """Mock the TaskAPI dependency for connectivity checks.""" - mock = AsyncMock(spec=RemoteAPI) - sep_app.dependency_overrides[get_tasks_api] = lambda: mock - yield mock - sep_app.dependency_overrides = {} - - @pytest.fixture - def mysql_service(self, created_node) -> CreatedService: - """Return a MySQL service with a node and port.""" - service = CreatedServiceFactory.build(type=ServiceTypeEnum.MYSQL, port=3306) - service.node = created_node - return service - - @pytest.fixture - def _mock_mysql_service_dep(self, mysql_service): - """Mock CreatedServiceDep with a MySQL service.""" - sep_app.dependency_overrides[get_created_service] = lambda: mysql_service - yield - sep_app.dependency_overrides = {} - - @pytest.fixture - def _mock_executor_hosts(self, mysql_service, mock_tasks_api_dep): - """Pre-populate ``GET /hosts/`` so address lookup resolves to the node name. - - The connectivity check resolves the executor target by address before - posting; tests that exercise the post path need a matching entry in - the executor hosts mapping or the route short-circuits with an - "address not registered" flash instead. - """ - mock_tasks_api_dep.get.return_value = { - mysql_service.node.name: mysql_service.node.address, - } - - @pytest.mark.parametrize( - "service_type", - [ - ServiceTypeEnum.MYSQL, - ServiceTypeEnum.POSTGRESQL, - ServiceTypeEnum.MONGODB, - ], - ) - def test_connectivity_check_success( - self, - test_client, - created_node, - mock_tasks_api_dep, - service_type, - ): - """Verify redirect, success flash, and lowercase ``service_type`` in payload.""" - service = CreatedServiceFactory.build(type=service_type, port=3306) - service.node = created_node - sep_app.dependency_overrides[get_created_service] = lambda: service - mock_tasks_api_dep.get.return_value = { - service.node.name: service.node.address, - } - mock_tasks_api_dep.post.return_value = { - "success": True, - "error": None, - "task_history_id": 42, - } - try: - response = test_client.post( - f"/inventory/services/{service.id}/check-connectivity/", - follow_redirects=False, - ) - finally: - sep_app.dependency_overrides.pop(get_created_service, None) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"] == f"/inventory/services/{service.id}" - assert _flashed_texts(response) == [ - f"Connectivity check passed for {service.name}" - ] - mock_tasks_api_dep.get.assert_awaited_once_with("/hosts/") - mock_tasks_api_dep.post.assert_awaited_once_with( - "/connectivity-check/", - json={ - "target": service.node.name, - "host": service.node.address, - "port": service.port, - "service_type": service_type.value, - }, - ) - - def test_connectivity_check_resolves_executor_name_by_address( - self, - test_client, - created_node, - mock_tasks_api_dep, - ): - """Reproduce the case where inventory name and executor name differ. - - When the inventory display name does not match the Nomad node name, - the connectivity check must look the executor target up by address - (the same reconciliation the host dropdown does) instead of passing - the inventory name straight through. - """ - service = CreatedServiceFactory.build(type=ServiceTypeEnum.MYSQL, port=3306) - service.node = created_node - executor_node_name = f"{created_node.name}-nomad-mismatch" - sep_app.dependency_overrides[get_created_service] = lambda: service - mock_tasks_api_dep.get.return_value = { - executor_node_name: service.node.address, - } - mock_tasks_api_dep.post.return_value = { - "success": True, - "error": None, - "task_history_id": 42, - } - try: - response = test_client.post( - f"/inventory/services/{service.id}/check-connectivity/", - follow_redirects=False, - ) - finally: - sep_app.dependency_overrides.pop(get_created_service, None) - assert response.status_code == status.HTTP_303_SEE_OTHER - mock_tasks_api_dep.post.assert_awaited_once_with( - "/connectivity-check/", - json={ - "target": executor_node_name, - "host": service.node.address, - "port": service.port, - "service_type": ServiceTypeEnum.MYSQL.value, - }, - ) - - @pytest.mark.usefixtures("_mock_mysql_service_dep") - def test_connectivity_check_address_not_registered( - self, test_client, mysql_service, mock_tasks_api_dep - ): - """Surface a flash and skip the API call when no executor matches the address.""" - mock_tasks_api_dep.get.return_value = {"some-other-nomad": "10.0.0.99"} - response = test_client.post( - f"/inventory/services/{mysql_service.id}/check-connectivity/", - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"] == f"/inventory/services/{mysql_service.id}" - assert _flashed_texts(response) == [ - f"Connectivity check failed for {mysql_service.name}: " - f"no executor host is registered for address " - f"{mysql_service.node.address!r} (inventory node {mysql_service.node.name!r})." - ] - mock_tasks_api_dep.post.assert_not_awaited() - - @pytest.mark.usefixtures("_mock_mysql_service_dep") - def test_connectivity_check_executor_hosts_fetch_fails( - self, test_client, mysql_service, mock_tasks_api_dep - ): - """Surface a flash and skip the API call when fetching hosts fails.""" - mock_tasks_api_dep.get.side_effect = ConnectionError("tasks unreachable") - response = test_client.post( - f"/inventory/services/{mysql_service.id}/check-connectivity/", - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert _flashed_texts(response) == [ - f"Connectivity check failed for {mysql_service.name}: " - "could not reach the Tasks API" - ] - mock_tasks_api_dep.post.assert_not_awaited() - - @pytest.mark.usefixtures("_mock_mysql_service_dep") - def test_connectivity_check_executor_hosts_http_exception_surfaces_detail( - self, test_client, mysql_service, mock_tasks_api_dep - ): - """Surface the upstream ``detail`` when the executor-hosts GET raises HTTPException. - - Pin the GET-path counterpart of - :meth:`test_connectivity_check_http_exception_surfaces_detail` so the - ``except HTTPException`` branch keeps forwarding ``exc.detail`` into - the flash message verbatim, instead of falling through to the generic - "could not reach the Tasks API" string from the bare ``except``. - """ - mock_tasks_api_dep.get.side_effect = HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Nomad unavailable", - ) - response = test_client.post( - f"/inventory/services/{mysql_service.id}/check-connectivity/", - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"] == f"/inventory/services/{mysql_service.id}" - assert _flashed_texts(response) == [ - f"Connectivity check failed for {mysql_service.name}: Nomad unavailable" - ] - mock_tasks_api_dep.post.assert_not_awaited() - - @pytest.mark.usefixtures("_mock_mysql_service_dep", "_mock_executor_hosts") - def test_connectivity_check_failure( - self, test_client, mysql_service, mock_tasks_api_dep - ): - """Verify redirect with error flash when connectivity check fails.""" - mock_tasks_api_dep.post.return_value = { - "success": False, - "error": "Connection refused", - "task_history_id": 43, - } - response = test_client.post( - f"/inventory/services/{mysql_service.id}/check-connectivity/", - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"] == f"/inventory/services/{mysql_service.id}" - assert _flashed_texts(response) == [ - f"Connectivity check failed for {mysql_service.name}: Connection refused" - ] - - @pytest.mark.usefixtures("_mock_mysql_service_dep", "_mock_executor_hosts") - def test_connectivity_check_tasks_api_error( - self, test_client, mysql_service, mock_tasks_api_dep - ): - """Verify redirect with error flash when Tasks API raises an exception.""" - mock_tasks_api_dep.post.side_effect = ConnectionError("timeout") - response = test_client.post( - f"/inventory/services/{mysql_service.id}/check-connectivity/", - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"] == f"/inventory/services/{mysql_service.id}" - assert _flashed_texts(response) == [ - f"Connectivity check failed for {mysql_service.name}: " - "could not reach the Tasks API" - ] - - @pytest.mark.usefixtures("_mock_mysql_service_dep") - def test_connectivity_check_service_without_node( - self, test_client, mysql_service, mock_tasks_api_dep - ): - """Verify error flash when service has no associated node.""" - mysql_service.node = None - response = test_client.post( - f"/inventory/services/{mysql_service.id}/check-connectivity/", - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert _flashed_texts(response) == [ - f"Connectivity check failed for {mysql_service.name}: " - "missing node or port information" - ] - mock_tasks_api_dep.post.assert_not_awaited() - - @pytest.mark.usefixtures("_mock_mysql_service_dep") - def test_connectivity_check_service_without_port( - self, test_client, mysql_service, mock_tasks_api_dep - ): - """Verify error flash when service has no port.""" - mysql_service.port = None - response = test_client.post( - f"/inventory/services/{mysql_service.id}/check-connectivity/", - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert _flashed_texts(response) == [ - f"Connectivity check failed for {mysql_service.name}: " - "missing node or port information" - ] - mock_tasks_api_dep.post.assert_not_awaited() - - @pytest.mark.parametrize("service_type", _UNSUPPORTED_SERVICE_TYPES) - def test_connectivity_check_rejects_unsupported_service_type( - self, - test_client, - created_node, - mock_tasks_api_dep, - service_type, - ): - """Verify the route rejects unsupported service types server-side.""" - service = CreatedServiceFactory.build(type=service_type, port=3306) - service.node = created_node - sep_app.dependency_overrides[get_created_service] = lambda: service - try: - response = test_client.post( - f"/inventory/services/{service.id}/check-connectivity/", - follow_redirects=False, - ) - finally: - sep_app.dependency_overrides.pop(get_created_service, None) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"] == f"/inventory/services/{service.id}" - assert _flashed_texts(response) == [ - f"Connectivity check is not supported for {service_type.name} services" - ] - mock_tasks_api_dep.post.assert_not_awaited() - - @pytest.mark.usefixtures("_mock_mysql_service_dep", "_mock_executor_hosts") - def test_connectivity_check_http_exception_surfaces_detail( - self, test_client, mysql_service, mock_tasks_api_dep - ): - """Verify HTTPException from Tasks API is handled and does not blow up.""" - mock_tasks_api_dep.post.side_effect = HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Nomad unavailable", - ) - response = test_client.post( - f"/inventory/services/{mysql_service.id}/check-connectivity/", - follow_redirects=False, - ) - assert response.status_code == status.HTTP_303_SEE_OTHER - assert response.headers["location"] == f"/inventory/services/{mysql_service.id}" - assert _flashed_texts(response) == [ - f"Connectivity check failed for {mysql_service.name}: Nomad unavailable" - ] - - @pytest.mark.parametrize( - "service_type", - [ - ServiceTypeEnum.MYSQL, - ServiceTypeEnum.POSTGRESQL, - ServiceTypeEnum.MONGODB, - ], - ) - @pytest.mark.usefixtures("mock_sync_item_manager", "mock_get_username_mapping") - def test_connectivity_button_visible_for_connectable_types( - self, - test_client, - created_node, - service_type, - ): - """Verify the connectivity check button renders for connectable service types.""" - service = CreatedServiceFactory.build(type=service_type, port=3306) - service.node = created_node - sep_app.dependency_overrides[get_created_service] = lambda: service - try: - response = test_client.get(f"/inventory/services/{service.id}") - assert response.status_code == status.HTTP_200_OK - assert "check-connectivity" in response.text - finally: - sep_app.dependency_overrides.pop(get_created_service, None) - - @pytest.mark.parametrize("service_type", _UNSUPPORTED_SERVICE_TYPES) - @pytest.mark.usefixtures("mock_sync_item_manager", "mock_get_username_mapping") - def test_connectivity_button_hidden_for_non_connectable_types( - self, - test_client, - created_node, - service_type, - ): - """Verify the connectivity check button does not render for non-connectable types.""" - service = CreatedServiceFactory.build(type=service_type, port=3306) - service.node = created_node - sep_app.dependency_overrides[get_created_service] = lambda: service - try: - response = test_client.get(f"/inventory/services/{service.id}") - assert response.status_code == status.HTTP_200_OK - assert "check-connectivity" not in response.text - finally: - sep_app.dependency_overrides.pop(get_created_service, None) - - -def _assert_available_syncers_context(template_spy): - """Assert the spy captured an ``available_syncers`` context list of two stubs.""" - template_spy.assert_called_once() - _, kwargs = template_spy.call_args - available = kwargs["context"]["available_syncers"] - assert len(available) == EXPECTED_STUB_COUNT - assert all(isinstance(entry, AvailableSyncer) for entry in available) - assert {entry.name for entry in available} == {PMM_STUB_NAME, MYSQL_STUB_NAME} - assert {entry.display_name for entry in available} == {"StubPMM", "StubMySQL"} - assert kwargs["context"]["can_sync"] is True - - -@pytest.mark.usefixtures("mock_sync_item_manager", "mock_get_username_mapping") -def test_node_list_context_has_available_syncers( - mocker, test_client, mock_inventory_api_dep, mock_task_api_dep, mock_syncers -): - """Render the node list and assert ``available_syncers`` reaches the template.""" - mock_task_api_dep.get.return_value = [] - template_spy = mocker.spy(templates, "TemplateResponse") - response = test_client.get("/inventory/") - assert response.status_code == status.HTTP_200_OK - _assert_available_syncers_context(template_spy) - - -@pytest.mark.usefixtures( - "_mock_created_node_dep", "mock_sync_item_manager", "mock_get_username_mapping" -) -def test_node_detail_context_has_available_syncers( - mocker, test_client, created_node, mock_syncers -): - """Render the node detail page and assert the context carries the syncers.""" - template_spy = mocker.spy(templates, "TemplateResponse") - response = test_client.get(f"/inventory/{created_node.id}") - assert response.status_code == status.HTTP_200_OK - _assert_available_syncers_context(template_spy) - - -@pytest.mark.usefixtures( - "_mock_created_service_dep", "mock_sync_item_manager", "mock_get_username_mapping" -) -def test_service_detail_context_has_available_syncers( - mocker, test_client, created_service, mock_syncers -): - """Render the service detail page and assert the context carries the syncers.""" - template_spy = mocker.spy(templates, "TemplateResponse") - response = test_client.get(f"/inventory/services/{created_service.id}") - assert response.status_code == status.HTTP_200_OK - _assert_available_syncers_context(template_spy) - - -@pytest.mark.usefixtures( - "_mock_created_schema_dep", "mock_sync_item_manager", "mock_get_username_mapping" -) -def test_schema_detail_context_has_available_syncers( - mocker, test_client, created_schema, mock_syncers -): - """Render the schema detail page and assert the context carries the syncers.""" - template_spy = mocker.spy(templates, "TemplateResponse") - response = test_client.get(f"/inventory/schemas/{created_schema.id}") - assert response.status_code == status.HTTP_200_OK - _assert_available_syncers_context(template_spy) - - -@pytest.mark.usefixtures("mock_sync_item_manager", "mock_get_username_mapping") -def test_node_list_context_is_empty_when_no_syncers_configured( - mocker, test_client, mock_inventory_api_dep, mock_task_api_dep -): - """Render the node list with zero configured syncers — sync UI is hidden.""" - mock_task_api_dep.get.return_value = [] - sep_app.dependency_overrides[get_syncers] = no_syncers - template_spy = mocker.spy(templates, "TemplateResponse") - try: - response = test_client.get("/inventory/") - assert response.status_code == status.HTTP_200_OK - _, kwargs = template_spy.call_args - assert kwargs["context"]["available_syncers"] == [] - assert kwargs["context"]["can_sync"] is False - finally: - sep_app.dependency_overrides = {} - - -_INVENTORY_SYNC_PERIODIC_ID = 7 - - -def _compact(text: str) -> str: - """Collapse whitespace runs in rendered HTML for substring assertions. - - The djlint formatter splits HTML attributes onto separate lines in the - template source; the rendered output preserves that whitespace, so plain - substring matches against multi-attribute fragments fail. Normalising any - run of whitespace to a single space lets tests assert against the logical - content without binding to formatter output. - - :param text: The rendered HTML body. - :type text: str - :return: The body with consecutive whitespace collapsed to single spaces. - :rtype: str - """ - return re.sub(r"\s+", " ", text) - - -def _execute_request_for(syncer: str | None) -> dict | None: - """Build the ``execute_request`` blob for a periodic-task fake row. - - :param syncer: Fully qualified syncer name to embed in ``meta``, or - ``None`` to omit ``execute_request`` entirely (legacy / sync-all rows). - :type syncer: str | None - :return: A JSON-serialisable ``execute_request`` dict, or ``None``. - :rtype: dict | None - """ - if syncer is None: - return None - return { - "task": "inventory-sync", - "target": "local", - "meta": {"syncer": syncer}, - "payload": None, - "tracking": {"allocation_id": None, "evaluation_id": None}, - "eta": None, - } - - -def _interval_periodic( - *, - every: int = 5, - period: str = "minutes", - enabled: bool = True, - task_id: int = _INVENTORY_SYNC_PERIODIC_ID, - syncer: str | None = None, -) -> dict: - """Build a fake interval-mode inventory-sync periodic-task JSON row.""" - return { - "id": task_id, - "name": "inventory-sync", - "task": "inventory-sync", - "start_time": None, - "enabled": enabled, - "description": "", - "execute_request": _execute_request_for(syncer), - "interval": {"every": every, "period": period}, - "crontab": None, - "last_run_at": None, - "total_run_count": 0, - "date_changed": None, - "next_run_at": None, - "period": f"every {every} {period[:-1] if every == 1 else period}", - } - - -def _crontab_periodic( - *, - minute: str = "0", - hour: str = "0", - day_of_month: str = "*", - month_of_year: str = "*", - day_of_week: str = "*", - timezone: str = "Europe/Berlin", - enabled: bool = True, - task_id: int = _INVENTORY_SYNC_PERIODIC_ID, - syncer: str | None = None, -) -> dict: - """Build a fake crontab-mode inventory-sync periodic-task JSON row.""" - return { - "id": task_id, - "name": "inventory-sync", - "task": "inventory-sync", - "start_time": None, - "enabled": enabled, - "description": "", - "execute_request": _execute_request_for(syncer), - "interval": None, - "crontab": { - "minute": minute, - "hour": hour, - "day_of_month": day_of_month, - "month_of_year": month_of_year, - "day_of_week": day_of_week, - "timezone": timezone, - }, - "last_run_at": None, - "total_run_count": 0, - "date_changed": None, - "next_run_at": None, - "period": (f"{minute} {hour} {day_of_month} {month_of_year} {day_of_week}"), - } - - -@pytest.mark.usefixtures( - "mock_sync_item_manager", "mock_get_username_mapping", "mock_syncers" -) -def test_node_list_renders_no_schedule_state( - test_client, mock_inventory_api_dep, mock_task_api_dep -): - """Render the no-schedule state when the periodic-tasks API returns no rows.""" - mock_task_api_dep.get.return_value = [] - response = test_client.get("/inventory/") - assert response.status_code == status.HTTP_200_OK - body = _compact(response.text) - assert "No schedule configured" in body - assert "Attach a schedule" in body - assert 'action="http://testserver/inventory/schedule/"' in body - - -@pytest.mark.usefixtures( - "mock_sync_item_manager", "mock_get_username_mapping", "mock_syncers" -) -def test_node_list_renders_interval_schedule_enabled( - test_client, mock_inventory_api_dep, mock_task_api_dep -): - """Render the interval-mode schedule with Disable, Edit, and Clear actions.""" - mock_task_api_dep.get.return_value = [_interval_periodic()] - response = test_client.get("/inventory/") - assert response.status_code == status.HTTP_200_OK - body = _compact(response.text) - assert "every 5 minutes" in body - assert "Disable" in body - assert "Edit Schedule" in body - assert "Clear Schedule" in body - assert f"/periodic/{_INVENTORY_SYNC_PERIODIC_ID}/update" in body - assert f"/periodic/{_INVENTORY_SYNC_PERIODIC_ID}/delete" in body - assert 'name="enabled" value="false"' in body - - -@pytest.mark.usefixtures( - "mock_sync_item_manager", "mock_get_username_mapping", "mock_syncers" -) -def test_node_list_renders_interval_schedule_disabled( - test_client, mock_inventory_api_dep, mock_task_api_dep -): - """Render the disabled state with an Enable toggle and a "(disabled)" marker.""" - mock_task_api_dep.get.return_value = [_interval_periodic(enabled=False)] - response = test_client.get("/inventory/") - assert response.status_code == status.HTTP_200_OK - body = _compact(response.text) - assert "(disabled)" in body - assert "Enable" in body - assert 'name="enabled" value="true"' in body - - -@pytest.mark.usefixtures( - "mock_sync_item_manager", "mock_get_username_mapping", "mock_syncers" -) -def test_node_list_renders_crontab_schedule( - test_client, mock_inventory_api_dep, mock_task_api_dep -): - """Render a crontab-mode schedule with the cron expression and timezone.""" - mock_task_api_dep.get.return_value = [ - _crontab_periodic( - minute="0", hour="0", day_of_month="*", month_of_year="*", day_of_week="*" - ) - ] - response = test_client.get("/inventory/") - assert response.status_code == status.HTTP_200_OK - body = _compact(response.text) - assert "0 0 * * *" in body - assert "Europe/Berlin" in body - assert 'value="crontab"' in body - - -@pytest.mark.usefixtures( - "mock_sync_item_manager", "mock_get_username_mapping", "mock_syncers" -) -def test_node_list_renders_persisted_start_time( - test_client, mock_inventory_api_dep, mock_task_api_dep -): - """Surface ``start_time`` on the schedule block when the persisted row has it set.""" - schedule = _interval_periodic() - schedule["start_time"] = "2026-05-01T09:00:00+00:00" - mock_task_api_dep.get.return_value = [schedule] - response = test_client.get("/inventory/") - assert response.status_code == status.HTTP_200_OK - body = _compact(response.text) - assert "Starts at:" in body - assert "2026-05-01T09:00:00+00:00" in body - - -@pytest.mark.usefixtures("mock_sync_item_manager", "mock_get_username_mapping") -def test_node_list_includes_available_timezones_in_context( - mocker, test_client, mock_inventory_api_dep, mock_task_api_dep, mock_syncers -): - """Pass ``AVAILABLE_TIMEZONES`` through to the template context.""" - mock_task_api_dep.get.return_value = [] - template_spy = mocker.spy(templates, "TemplateResponse") - response = test_client.get("/inventory/") - assert response.status_code == status.HTTP_200_OK - _, kwargs = template_spy.call_args - assert kwargs["context"]["AVAILABLE_TIMEZONES"] == AVAILABLE_TIMEZONES - - -@pytest.mark.usefixtures( - "mock_sync_item_manager", "mock_get_username_mapping", "mock_syncers" -) -def test_node_list_attach_form_uses_inventory_schedule_create_route( - test_client, mock_inventory_api_dep, mock_task_api_dep -): - """Render the attach form pointing at the inventory-specific create route.""" - mock_task_api_dep.get.return_value = [] - response = test_client.get("/inventory/") - assert response.status_code == status.HTTP_200_OK - body = _compact(response.text) - assert 'action="http://testserver/inventory/schedule/"' in body - assert 'name="task" value="inventory-sync"' not in body - - -@pytest.mark.usefixtures( - "mock_sync_item_manager", "mock_get_username_mapping", "mock_syncers" -) -def test_node_list_pre_fills_form_for_existing_interval_schedule( - test_client, mock_inventory_api_dep, mock_task_api_dep -): - """Pre-fill the edit form's interval inputs from the existing schedule.""" - mock_task_api_dep.get.return_value = [_interval_periodic(every=15, period="hours")] - response = test_client.get("/inventory/") - assert response.status_code == status.HTTP_200_OK - body = _compact(response.text) - assert 'name="interval_every"' in body - assert 'value="15"' in body - assert '