From b6d567b4296355ee0017445a9d6d682c6af525d8 Mon Sep 17 00:00:00 2001 From: Kevin Minehart <5140827+kminehart@users.noreply.github.com> Date: Wed, 17 Dec 2025 22:55:59 +0100 Subject: [PATCH 001/229] Docs: remove software-properties-common; it is unused and not available in debian:13 (#115482) --- docs/sources/setup-grafana/installation/debian/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sources/setup-grafana/installation/debian/index.md b/docs/sources/setup-grafana/installation/debian/index.md index 067bcb29f6a..900c4a4b9d1 100644 --- a/docs/sources/setup-grafana/installation/debian/index.md +++ b/docs/sources/setup-grafana/installation/debian/index.md @@ -46,7 +46,7 @@ Complete the following steps to install Grafana from the APT repository: 1. Install the prerequisite packages: ```bash - sudo apt-get install -y apt-transport-https software-properties-common wget + sudo apt-get install -y apt-transport-https wget ``` 1. Import the GPG key: From 0b233d20dde15a3f0d2fc7b32bbb7f88bb6800e5 Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Thu, 18 Dec 2025 00:40:27 +0000 Subject: [PATCH 002/229] I18n: Download translations from Crowdin (#115527) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- public/locales/cs-CZ/grafana.json | 30 +++++++++++------------------ public/locales/de-DE/grafana.json | 30 +++++++++++------------------ public/locales/es-ES/grafana.json | 30 +++++++++++------------------ public/locales/fr-FR/grafana.json | 30 +++++++++++------------------ public/locales/hu-HU/grafana.json | 30 +++++++++++------------------ public/locales/id-ID/grafana.json | 30 +++++++++++------------------ public/locales/it-IT/grafana.json | 30 +++++++++++------------------ public/locales/ja-JP/grafana.json | 30 +++++++++++------------------ public/locales/ko-KR/grafana.json | 30 +++++++++++------------------ public/locales/nl-NL/grafana.json | 30 +++++++++++------------------ public/locales/pl-PL/grafana.json | 30 +++++++++++------------------ public/locales/pt-BR/grafana.json | 30 +++++++++++------------------ public/locales/pt-PT/grafana.json | 30 +++++++++++------------------ public/locales/ru-RU/grafana.json | 30 +++++++++++------------------ public/locales/sv-SE/grafana.json | 30 +++++++++++------------------ public/locales/tr-TR/grafana.json | 30 +++++++++++------------------ public/locales/zh-Hans/grafana.json | 30 +++++++++++------------------ public/locales/zh-Hant/grafana.json | 30 +++++++++++------------------ 18 files changed, 198 insertions(+), 342 deletions(-) diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index f933e337d3c..575bba19ca1 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -3351,6 +3351,7 @@ "login-attribute-path-label": "Cesta atributu přihlášení", "login-prompt-consent": "Souhlas", "login-prompt-description": "Označuje typ interakce uživatele při přihlášení pomocí IdP.", + "login-prompt-description-google": "", "login-prompt-label": "Výzva k přihlášení", "login-prompt-login": "Přihlášení", "login-prompt-select-account": "Vyberte účet", @@ -3408,6 +3409,7 @@ "use-pkce-description": "Pokud je povoleno, Grafana použije <2>ověřovací klíč pro výměnu kódů (PKCE) s udělením autorizačního kódu OAuth2.", "use-pkce-label": "Použít PKCE", "use-refresh-token-description": "Pokud je povoleno, Grafana načte nový přístupový token pomocí obnovovacího tokenu poskytnutého poskytovatelem OAuth2.", + "use-refresh-token-description-google": "", "use-refresh-token-label": "Použít token pro obnovení", "validate-hosted-domain-description": "Pokud je povoleno, Grafana porovná hostovanou doménu načtenou z tokenu Google ID se seznamem „{{ allowedDomainsLabel }}“ zadaným uživatelem.", "validate-hosted-domain-label": "Ověřit hostovanou doménu", @@ -10267,7 +10269,8 @@ "copy": "Kopírovat výběr", "disable-menu": "Zakázat nabídku", "line-contains": "Přidat jako řádek obsahuje filtr", - "line-contains-not": "Přidat jako řádek neobsahuje filtr" + "line-contains-not": "Přidat jako řádek neobsahuje filtr", + "search-text": "" }, "show-log-attributes": "", "timestamp-format": "Rozlišení časového razítka", @@ -11973,7 +11976,6 @@ } }, "getting-started": { - "alert-temporary-outage": "Když připojíte celou instanci, nástěnky nebudou během migrace k dispozici. Doporučujeme uživatele před zahájením procesu varovat.", "modal-description-public-access": "Nastavte veřejný přístup k vaší instanci Grafana pro povolení integrace GitHubu", "modal-description-required-features": "Povolit požadované funkce Grafany pro zajištění", "modal-title-set-up-public-access": "Nastavit veřejný přístup", @@ -11985,8 +11987,7 @@ "step-title-copy-url": "Kopírovat veřejnou adresu URL", "step-title-enable-feature-toggles": "Povolit požadované přepínače funkcí", "step-title-start-ngrok": "Spustit „ngrok“ pro dočasný veřejný přístup", - "step-title-update-grafana-config": "Aktualizujte konfiguraci Grafany", - "title-setting-connection-could-cause-temporary-outage": "Nastavení tohoto připojení může způsobit dočasný výpadek" + "step-title-update-grafana-config": "Aktualizujte konfiguraci Grafany" }, "getting-started-page": { "subtitle-provisioning-feature": "Zobrazujte a spravujte vazby zajištění" @@ -12055,18 +12056,15 @@ }, "home-page": { "button-delete-repositories": "Odstranit úložiště", - "configured-repositories-while-running-legacy-storage": "Konfigurovaná úložiště nebudou fungovat při spuštění starší verze úložiště.", "confirm-delete-repositories": "Opravdu chcete odstranit všechna nakonfigurovaná úložiště? Tato akce je nevratná.", "error-delete-all-repositories": "Nepodařilo se odstranit všechna úložiště", - "remove-all-configured-repositories": "Odebrat všechna nakonfigurovaná úložiště", "subtitle": "Zobrazujte a spravujte nakonfigurovaná úložiště", "success-all-repositories-deleted": "Všechna nakonfigurovaná úložiště byla odstraněna", "tab-getting-started": "Začínáme", "tab-getting-started-title": "Začínáme", "tab-repositories": "Úložiště", "tab-repositories-title": "Seznam úložišť", - "title-delete-all-configured-repositories": "Odstranit všechna nakonfigurovaná úložiště", - "title-legacy-storage-detected": "Bylo zjištěno starší úložiště" + "title-delete-all-configured-repositories": "Odstranit všechna nakonfigurovaná úložiště" }, "http-utils": { "access-denied": "", @@ -12106,7 +12104,6 @@ "mode-options": { "disabled": { "folder-connected": "", - "legacy-storage": "", "not-allowed": "", "not-supported": "" }, @@ -12186,7 +12183,6 @@ "repository-status-page": { "back-to-repositories": "Zpět na úložiště", "cleaning-up-resources": "Čištění zdrojů úložiště", - "legacy-storage-message": "Instance nespustila sjednocené úložiště – vyžaduje průvodce migrací", "not-found": "nenalezeno", "not-found-message": "Úložiště nebylo nalezeno", "repository-config-exists-configuration": "Ujistěte se, že konfigurace úložiště existuje v konfiguračním souboru.", @@ -12195,7 +12191,6 @@ "tab-resources": "Zdroje", "tab-resources-title": "", "title": "Stav úložiště", - "title-legacy-storage": "Starší verze úložiště", "title-queued-for-deletion": "Zařazeno do fronty k odstranění" }, "repository-type-cards": { @@ -12291,9 +12286,7 @@ "synchronize-step": { "repository-error": "", "repository-error-message": "", - "repository-unhealthy": "", - "synchronization-description": "Zahrnout předložení pro každou historickou hodnotu", - "synchronization-options": "Možnosti synchronizace" + "repository-unhealthy": "" }, "token-permissions-info": { "and-click": "a klikněte", @@ -12310,11 +12303,11 @@ }, "warning-title-default": "Varování", "wizard": { - "alert-point-1": "Během tohoto procesu nebude možné vytvářet, upravovat ani odstraňovat zdroje. V posledním kroku zmizí.", - "alert-point-2": "Po zajištění se zdroje znovu zobrazí a budou spravovány prostřednictvím externího úložiště.", + "alert-point-1": "", + "alert-point-2": "", "alert-point-3": "Doba trvání tohoto procesu závisí na počtu zapojených zdrojů.", - "alert-point-4": "Správci podnikových instancí mohou uživatelům zobrazit banner oznámení. Podrobné pokyny najdete v <2>této příručce.", - "alert-title": "Důležité: Nebudou ztracena žádná data ani konfigurace, ale nástěnky budou na několik minut dočasně nedostupné.", + "alert-point-4": "", + "alert-title": "", "button-cancel": "", "button-cancelling": "", "button-next": "Dokončit", @@ -12332,7 +12325,6 @@ "step-finish": "Vybrat další nastavení", "step-synchronize": "Synchronizovat s externím úložištěm", "sync-description": "Synchronizujte zdroje s externím úložištěm. Po tomto jednorázovém kroku budou všechny budoucí aktualizace automaticky uloženy do úložiště a zajištěny zpět do instance.", - "sync-option-history": "Historie", "title-bootstrap": "Vyberte, co chcete synchronizovat", "title-connect": "Připojit k externímu úložišti", "title-finish": "Vybrat další nastavení", diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 7fdd08a6a13..9df0dd8a2da 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -3329,6 +3329,7 @@ "login-attribute-path-label": "Anmeldeattributpfad", "login-prompt-consent": "Einverständnis", "login-prompt-description": "Gibt die Art der Nutzerinteraktion an, wenn sich der Nutzer mit dem IdP anmeldet.", + "login-prompt-description-google": "", "login-prompt-label": "Anmeldeaufforderung", "login-prompt-login": "Anmelden", "login-prompt-select-account": "Account auswählen", @@ -3386,6 +3387,7 @@ "use-pkce-description": "Wenn dies aktiviert ist, nutzt Grafana <2>Proof Key for Code Exchange (PKCE) mit OAuth2 Authorization Code Grant.", "use-pkce-label": "PKCE verwenden", "use-refresh-token-description": "Wenn dies aktiviert ist, ruft Grafana einen neuen Zugriffstoken mit dem vom OAuth2-Anbieter bereitgestellten Aktualisierungstoken ab.", + "use-refresh-token-description-google": "", "use-refresh-token-label": "Aktualisierungstoken nutzen", "validate-hosted-domain-description": "Wenn dies aktiviert ist, wird Grafana die vom Google-ID-Token abgerufene gehostete Domain mit der vom Nutzer spezifizierten Liste „{{ allowedDomainsLabel }}“ abgleichen.", "validate-hosted-domain-label": "Gehostete Domain validieren", @@ -10181,7 +10183,8 @@ "copy": "Auswahl kopieren", "disable-menu": "Menü deaktivieren", "line-contains": "„Als Zeile hinzufügen“ enthält Filter", - "line-contains-not": "„Als Zeile hinzufügen“ enthält keinen Filter" + "line-contains-not": "„Als Zeile hinzufügen“ enthält keinen Filter", + "search-text": "" }, "show-log-attributes": "", "timestamp-format": "Zeitstempelauflösung", @@ -11871,7 +11874,6 @@ } }, "getting-started": { - "alert-temporary-outage": "Wenn Sie Ihre gesamte Instanz verbinden, sind während der Ausführung der Migration keine Dashboards verfügbar. Wir empfehlen Ihnen, Ihre Nutzer vor Beginn des Prozesses zu benachrichtigen.", "modal-description-public-access": "Richten Sie zur Aktivierung der GitHub-Integration den öffentlichen Zugriff auf Ihre Grafana-Instanz ein", "modal-description-required-features": "Benötigte Grafana-Funktionen für die Bereitstellung aktivieren", "modal-title-set-up-public-access": "Öffentlichen Zugriff einrichten", @@ -11883,8 +11885,7 @@ "step-title-copy-url": "Kopieren Sie Ihre öffentliche URL", "step-title-enable-feature-toggles": "Benötigte Funktionsschalter aktivieren", "step-title-start-ngrok": "ngrok für temporären öffentlichen Zugriff starten", - "step-title-update-grafana-config": "Aktualisieren Sie Ihre Grafana-Konfiguration", - "title-setting-connection-could-cause-temporary-outage": "Das Einrichten dieser Verbindung kann zu einem vorübergehenden Ausfall führen" + "step-title-update-grafana-config": "Aktualisieren Sie Ihre Grafana-Konfiguration" }, "getting-started-page": { "subtitle-provisioning-feature": "Sehen und verwalten Sie Ihre Bereitstellungsverbindungen" @@ -11953,18 +11954,15 @@ }, "home-page": { "button-delete-repositories": "Repositorys löschen", - "configured-repositories-while-running-legacy-storage": "Konfigurierte Repositorys funktionieren nicht, wenn veralteter Speicher ausgeführt wird.", "confirm-delete-repositories": "Sind Sie sicher, dass Sie alle konfigurierten Repositorys löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", "error-delete-all-repositories": "Es konnten nicht alle Repositorys gelöscht werden", - "remove-all-configured-repositories": "Alle konfigurierten Repositorys entfernen", "subtitle": "Sehen und verwalten Sie Ihre konfigurierten Repositorys", "success-all-repositories-deleted": "Alle konfigurierten Repositorys gelöscht", "tab-getting-started": "Erste Schritte", "tab-getting-started-title": "Erste Schritte", "tab-repositories": "Repositorys", "tab-repositories-title": "Liste der Repositorys", - "title-delete-all-configured-repositories": "Alle konfigurierten Repositorys löschen", - "title-legacy-storage-detected": "Veralteter Speicher erkannt" + "title-delete-all-configured-repositories": "Alle konfigurierten Repositorys löschen" }, "http-utils": { "access-denied": "", @@ -12004,7 +12002,6 @@ "mode-options": { "disabled": { "folder-connected": "", - "legacy-storage": "", "not-allowed": "", "not-supported": "" }, @@ -12084,7 +12081,6 @@ "repository-status-page": { "back-to-repositories": "Zurück zu den Repositorys", "cleaning-up-resources": "Bereinigen von Repository-Ressourcen", - "legacy-storage-message": "Die Instanz führt noch keinen Unified Storage aus – Migrationsassistent erforderlich", "not-found": "nicht gefunden", "not-found-message": "Repository nicht gefunden", "repository-config-exists-configuration": "Achten Sie darauf, dass die Repository-config in der Konfigurationsdatei vorhanden ist.", @@ -12093,7 +12089,6 @@ "tab-resources": "Ressourcen", "tab-resources-title": "", "title": "Repository-Status", - "title-legacy-storage": "Veralteter Speicher", "title-queued-for-deletion": "Zum Löschen in die Warteschlange gestellt" }, "repository-type-cards": { @@ -12189,9 +12184,7 @@ "synchronize-step": { "repository-error": "", "repository-error-message": "", - "repository-unhealthy": "", - "synchronization-description": "Commits für jeden historischen Wert erfassen", - "synchronization-options": "Synchronisierungsoptionen" + "repository-unhealthy": "" }, "token-permissions-info": { "and-click": "und klicken", @@ -12208,11 +12201,11 @@ }, "warning-title-default": "Warnung", "wizard": { - "alert-point-1": "Während dieses Vorgangs können keine Ressourcen erstellt, bearbeitet oder gelöscht werden. Im letzten Schritt werden sie entfernt.", - "alert-point-2": "Sobald die Bereitstellung abgeschlossen ist, werden die Ressourcen wieder angezeigt und über einen externen Speicher verwaltet.", + "alert-point-1": "", + "alert-point-2": "", "alert-point-3": "Die Dauer dieses Prozesses hängt von der Anzahl der betroffenen Ressourcen ab.", - "alert-point-4": "Administratoren von Enterprise-Instanzen können Nutzern gegenüber ein Ankündigungsbanner anzeigen. Schritt-für-Schritt-Anweisungen finden Sie in <2>dieser Anleitung.", - "alert-title": "Wichtig: Es gehen keine Daten oder Konfigurationen verloren, aber die Dashboards sind für einige Minuten vorübergehend nicht verfügbar.", + "alert-point-4": "", + "alert-title": "", "button-cancel": "", "button-cancelling": "", "button-next": "Fertigstellen", @@ -12230,7 +12223,6 @@ "step-finish": "Zusätzliche Einstellungen auswählen", "step-synchronize": "Mit externem Speicher synchronisieren", "sync-description": "Synchronisieren Sie Ressourcen mit externem Speicher. Nach diesem einmaligen Schritt werden alle zukünftigen Updates automatisch im Repository gespeichert und wieder in der Instanz bereitgestellt.", - "sync-option-history": "Verlauf", "title-bootstrap": "Wählen Sie aus, was synchronisiert wird", "title-connect": "Mit externem Speicher verbinden", "title-finish": "Zusätzliche Einstellungen auswählen", diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 5e36b5223c4..1c8c641cfad 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -3329,6 +3329,7 @@ "login-attribute-path-label": "Ruta del atributo de inicio de sesión", "login-prompt-consent": "Aceptar", "login-prompt-description": "Indica el tipo de interacción del usuario cuando este inicia sesión con el IdP.", + "login-prompt-description-google": "", "login-prompt-label": "Aviso de inicio de sesión", "login-prompt-login": "Iniciar sesión", "login-prompt-select-account": "Seleccionar cuenta", @@ -3386,6 +3387,7 @@ "use-pkce-description": "Si está habilitado, Grafana utilizará la <2>clave de prueba para el intercambio de código (PKCE) con la concesión de código de autorización OAuth2.", "use-pkce-label": "Utilizar PKCE", "use-refresh-token-description": "Si se ha habilitado, Grafana obtendrá un nuevo token de acceso a través del token de actualización que proporcione el proveedor de OAuth2.", + "use-refresh-token-description-google": "", "use-refresh-token-label": "Usar token de actualización", "validate-hosted-domain-description": "Si se ha habilitado, Grafana comparará el dominio alojado recuperado del token de ID de Google con la lista «{{ allowedDomainsLabel }}» que especifique el usuario.", "validate-hosted-domain-label": "Validar dominio alojado", @@ -10181,7 +10183,8 @@ "copy": "Copiar selección", "disable-menu": "Desactivar menú", "line-contains": "Añadir como filtro «la línea contiene»", - "line-contains-not": "Añadir como filtro «la línea no contiene»" + "line-contains-not": "Añadir como filtro «la línea no contiene»", + "search-text": "" }, "show-log-attributes": "", "timestamp-format": "Resolución de marca de tiempo", @@ -11871,7 +11874,6 @@ } }, "getting-started": { - "alert-temporary-outage": "Cuando conectes toda tu instancia, los paneles no estarán disponibles mientras se ejecuta la migración. Recomendamos advertir a tus usuarios antes de iniciar el proceso.", "modal-description-public-access": "Configura el acceso público a tu instancia de Grafana para habilitar la integración de GitHub", "modal-description-required-features": "Habilitar las funciones de Grafana necesarias para el aprovisionamiento", "modal-title-set-up-public-access": "Configurar el acceso público", @@ -11883,8 +11885,7 @@ "step-title-copy-url": "Copia tu URL pública", "step-title-enable-feature-toggles": "Habilitar las opciones de las funciones necesarias", "step-title-start-ngrok": "Iniciar ngrok para el acceso público temporal", - "step-title-update-grafana-config": "Actualiza tu configuración de Grafana", - "title-setting-connection-could-cause-temporary-outage": "Configurar esta conexión podría causar una interrupción temporal" + "step-title-update-grafana-config": "Actualiza tu configuración de Grafana" }, "getting-started-page": { "subtitle-provisioning-feature": "Ver y gestionar tus conexiones de aprovisionamiento" @@ -11953,18 +11954,15 @@ }, "home-page": { "button-delete-repositories": "Eliminar repositorios", - "configured-repositories-while-running-legacy-storage": "Los repositorios configurados no funcionarán mientras se ejecuta el almacenamiento heredado.", "confirm-delete-repositories": "¿Seguro que quieres eliminar todos los repositorios configurados? Esta acción no se puede deshacer.", "error-delete-all-repositories": "Error al eliminar todos los repositorios", - "remove-all-configured-repositories": "Eliminar todos los repositorios configurados", "subtitle": "Ver y gestionar tus repositorios configurados", "success-all-repositories-deleted": "Se han eliminado todos los repositorios configurados", "tab-getting-started": "Cómo empezar", "tab-getting-started-title": "Cómo empezar", "tab-repositories": "Repositorios", "tab-repositories-title": "Lista de repositorios", - "title-delete-all-configured-repositories": "Eliminar todos los repositorios configurados", - "title-legacy-storage-detected": "Almacenamiento heredado detectado" + "title-delete-all-configured-repositories": "Eliminar todos los repositorios configurados" }, "http-utils": { "access-denied": "", @@ -12004,7 +12002,6 @@ "mode-options": { "disabled": { "folder-connected": "", - "legacy-storage": "", "not-allowed": "", "not-supported": "" }, @@ -12084,7 +12081,6 @@ "repository-status-page": { "back-to-repositories": "Volver a los repositorios", "cleaning-up-resources": "Limpiando los recursos del repositorio", - "legacy-storage-message": "La instancia aún no está ejecutando almacenamiento unificado, requiere un asistente de migración", "not-found": "no ha sido encontrado", "not-found-message": "Repositorio no encontrado", "repository-config-exists-configuration": "Asegúrate de que la configuración del repositorio exista en el archivo de configuración.", @@ -12093,7 +12089,6 @@ "tab-resources": "Recursos", "tab-resources-title": "", "title": "Estado del repositorio", - "title-legacy-storage": "Almacenamiento heredado", "title-queued-for-deletion": "En cola para su eliminación" }, "repository-type-cards": { @@ -12189,9 +12184,7 @@ "synchronize-step": { "repository-error": "", "repository-error-message": "", - "repository-unhealthy": "", - "synchronization-description": "Incluir confirmaciones para cada valor histórico", - "synchronization-options": "Opciones de sincronización" + "repository-unhealthy": "" }, "token-permissions-info": { "and-click": "y haz clic en", @@ -12208,11 +12201,11 @@ }, "warning-title-default": "Advertencia", "wizard": { - "alert-point-1": "Los recursos no se podrán crear, editar ni eliminar durante este proceso. En el último paso, desaparecerán.", - "alert-point-2": "Una vez que se complete el aprovisionamiento, los recursos volverán a aparecer y se gestionarán a través de un almacenamiento externo.", + "alert-point-1": "", + "alert-point-2": "", "alert-point-3": "La duración de este proceso depende del número de recursos involucrados.", - "alert-point-4": "Los administradores de instancias empresariales pueden mostrar un báner de anuncio a los usuarios. Consulta <2>esta guía para obtener instrucciones paso a paso.", - "alert-title": "Importante: No se perderán datos ni configuraciones, pero los dashboards no estarán disponibles temporalmente durante unos minutos.", + "alert-point-4": "", + "alert-title": "", "button-cancel": "", "button-cancelling": "", "button-next": "Terminar", @@ -12230,7 +12223,6 @@ "step-finish": "Elegir ajustes adicionales", "step-synchronize": "Sincronizar con un almacenamiento externo", "sync-description": "Sincroniza los recursos con un almacenamiento externo. Después de este paso único, todas las actualizaciones futuras se guardarán automáticamente en el repositorio y se aprovisionarán de nuevo en la instancia.", - "sync-option-history": "Historial", "title-bootstrap": "Elegir qué sincronizar", "title-connect": "Conectar a un almacenamiento externo", "title-finish": "Elegir ajustes adicionales", diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index a6de953984c..d2faa93f8e5 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -3329,6 +3329,7 @@ "login-attribute-path-label": "Chemin d’attribut de connexion", "login-prompt-consent": "Consentement", "login-prompt-description": "Indique le type d’interaction utilisateur lors de la connexion via le fournisseur d’identité (IdP).", + "login-prompt-description-google": "", "login-prompt-label": "Invite de connexion", "login-prompt-login": "Connexion", "login-prompt-select-account": "Sélectionner un compte", @@ -3386,6 +3387,7 @@ "use-pkce-description": "Si cette option est activée, Grafana utilisera la <2>clé de preuve pour l’échange de code (PKCE) avec l’octroi de code d’autorisation OAuth2.", "use-pkce-label": "Utiliser PKCE", "use-refresh-token-description": "Si cette option est activée, Grafana récupérera un nouveau jeton d’accès à l’aide du jeton d’actualisation fourni par le fournisseur OAuth2.", + "use-refresh-token-description-google": "", "use-refresh-token-label": "Utiliser le jeton d’actualisation", "validate-hosted-domain-description": "Si cette option est activée, Grafana fera correspondre le domaine hébergé récupéré à partir du jeton d’identification Google à la liste « {{ allowedDomainsLabel }} » spécifiée par l’utilisateur.", "validate-hosted-domain-label": "Valider le domaine hébergé", @@ -10181,7 +10183,8 @@ "copy": "Copier la sélection", "disable-menu": "Désactiver le menu", "line-contains": "Ajouter car la ligne contient un filtre", - "line-contains-not": "Ajouter car la ligne ne contient pas de filtre" + "line-contains-not": "Ajouter car la ligne ne contient pas de filtre", + "search-text": "" }, "show-log-attributes": "", "timestamp-format": "Résolution des horodatages", @@ -11871,7 +11874,6 @@ } }, "getting-started": { - "alert-temporary-outage": "Lorsque vous connectez l’ensemble de votre instance, les tableaux de bord ne seront pas disponibles pendant l’exécution de la migration. Nous vous recommandons d’avertir vos utilisateurs avant de commencer le processus.", "modal-description-public-access": "Configurez l’accès public à votre instance Grafana pour activer l’intégration GitHub", "modal-description-required-features": "Activez les fonctionnalités Grafana requises pour la mise en service", "modal-title-set-up-public-access": "Configurer l’accès public", @@ -11883,8 +11885,7 @@ "step-title-copy-url": "Copier l’URL publique", "step-title-enable-feature-toggles": "Activer les bascules de fonctionnalité requises", "step-title-start-ngrok": "Démarrer ngrok pour un accès public temporaire", - "step-title-update-grafana-config": "Mettre à jour la configuration Grafana", - "title-setting-connection-could-cause-temporary-outage": "La configuration de cette connexion peut entraîner une interruption temporaire" + "step-title-update-grafana-config": "Mettre à jour la configuration Grafana" }, "getting-started-page": { "subtitle-provisioning-feature": "Afficher et gérer vos connexions de mise en service" @@ -11953,18 +11954,15 @@ }, "home-page": { "button-delete-repositories": "Supprimer les référentiels", - "configured-repositories-while-running-legacy-storage": "Les référentiels configurés ne fonctionneront pas lors de l’exécution du stockage hérité.", "confirm-delete-repositories": "Voulez-vous vraiment supprimer tous les référentiels configurés ? Cette action ne peut pas être annulée.", "error-delete-all-repositories": "Échec de la suppression de tous les référentiels", - "remove-all-configured-repositories": "Supprimer tous les référentiels configurés", "subtitle": "Afficher et gérer vos référentiels configurés", "success-all-repositories-deleted": "Tous les référentiels configurés ont été supprimés", "tab-getting-started": "Pour commencer", "tab-getting-started-title": "Pour commencer", "tab-repositories": "Référentiels", "tab-repositories-title": "Liste des référentiels", - "title-delete-all-configured-repositories": "Supprimer tous les référentiels configurés", - "title-legacy-storage-detected": "Stockage hérité détecté" + "title-delete-all-configured-repositories": "Supprimer tous les référentiels configurés" }, "http-utils": { "access-denied": "", @@ -12004,7 +12002,6 @@ "mode-options": { "disabled": { "folder-connected": "", - "legacy-storage": "", "not-allowed": "", "not-supported": "" }, @@ -12084,7 +12081,6 @@ "repository-status-page": { "back-to-repositories": "Retour aux référentiels", "cleaning-up-resources": "Nettoyage des ressources du référentiel", - "legacy-storage-message": "L’instance n’exécute pas encore de stockage unifié ; l’assistant de migration est requis", "not-found": "introuvable", "not-found-message": "Référentiel introuvable", "repository-config-exists-configuration": "Assurez-vous que la configuration du référentiel existe dans le fichier de configuration.", @@ -12093,7 +12089,6 @@ "tab-resources": "Ressources", "tab-resources-title": "", "title": "Statut du référentiel", - "title-legacy-storage": "Stockage hérité", "title-queued-for-deletion": "Mis en attente pour suppression" }, "repository-type-cards": { @@ -12189,9 +12184,7 @@ "synchronize-step": { "repository-error": "", "repository-error-message": "", - "repository-unhealthy": "", - "synchronization-description": "Inclure les commits pour chaque valeur historique", - "synchronization-options": "Options de synchronisation" + "repository-unhealthy": "" }, "token-permissions-info": { "and-click": "et cliquez", @@ -12208,11 +12201,11 @@ }, "warning-title-default": "Avertissement", "wizard": { - "alert-point-1": "Les ressources ne pourront pas être créées, modifiées ou supprimées pendant ce processus. Lors de la dernière étape, elles disparaîtront.", - "alert-point-2": "Une fois la mise en service terminée, les ressources réapparaîtront et seront gérées via un stockage externe.", + "alert-point-1": "", + "alert-point-2": "", "alert-point-3": "La durée de ce processus dépend du nombre de ressources impliquées.", - "alert-point-4": "Les administrateurs d’instances d’entreprise peuvent afficher une bannière d’annonce aux utilisateurs. Consultez <2>ce guide pour obtenir des instructions étape par étape.", - "alert-title": "Important : aucune donnée ou configuration ne sera perdue, mais les tableaux de bord seront temporairement indisponibles pendant quelques minutes.", + "alert-point-4": "", + "alert-title": "", "button-cancel": "", "button-cancelling": "", "button-next": "Terminer", @@ -12230,7 +12223,6 @@ "step-finish": "Choisir des paramètres supplémentaires", "step-synchronize": "Synchroniser avec un stockage externe", "sync-description": "Synchronisez les ressources avec un stockage externe. Après cette étape unique, toutes les futures mises à jour seront automatiquement enregistrées dans le référentiel et mises en service dans l’instance.", - "sync-option-history": "Historique", "title-bootstrap": "Choisir ce qui doit être synchronisé", "title-connect": "Se connecter à un stockage externe", "title-finish": "Choisir des paramètres supplémentaires", diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index c0250296917..0c8c164742b 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -3329,6 +3329,7 @@ "login-attribute-path-label": "Bejelentkezési attribútum útvonala", "login-prompt-consent": "Hozzájárulás", "login-prompt-description": "Jelzi a felhasználói interakció típusát, amikor a felhasználó az IdP-vel jelentkezik be.", + "login-prompt-description-google": "", "login-prompt-label": "Bejelentkezési felszólítás", "login-prompt-login": "Bejelentkezés", "login-prompt-select-account": "Számla kiválasztása", @@ -3386,6 +3387,7 @@ "use-pkce-description": "Ha engedélyezve van, a Grafana a <2>Proof Key for Code Exchange (PKCE) lehetőséget használja az OAuth2 engedélyezési kód megadásával.", "use-pkce-label": "PKCE használata", "use-refresh-token-description": "Ha engedélyezett, a Grafana új hozzáférési tokent fog lekérni az OAuth2-szolgáltató által biztosított frissítési token használatával.", + "use-refresh-token-description-google": "", "use-refresh-token-label": "Frissítési token használata", "validate-hosted-domain-description": "Ha engedélyezett, a Grafana a Google ID-tokennel lekérdezett üzemeltetett tartományt a felhasználó által megadott „{{ allowedDomainsLabel }}” listával próbálja meg egyeztetni.", "validate-hosted-domain-label": "Üzemeltetett tartomány validálása", @@ -10181,7 +10183,8 @@ "copy": "Kijelölés másolása", "disable-menu": "Menü letiltása", "line-contains": "A hozzáadás sorként szűrőt tartalmaz", - "line-contains-not": "A hozzáadás sorként nem tartalmaz szűrőt" + "line-contains-not": "A hozzáadás sorként nem tartalmaz szűrőt", + "search-text": "" }, "show-log-attributes": "", "timestamp-format": "Időbélyeg felbontása", @@ -11871,7 +11874,6 @@ } }, "getting-started": { - "alert-temporary-outage": "Ha az egész példányt csatlakoztatja, az áttelepítés futtatása során az irányítópultok nem lesznek elérhetők. Javasoljuk, hogy a folyamat megkezdése előtt figyelmeztesse a felhasználókat.", "modal-description-public-access": "Nyilvános hozzáférés beállítása a Grafana-példányhoz a GitHub-integráció engedélyezéséhez", "modal-description-required-features": "Szükséges Grafana-funkciók engedélyezése a kiépítéshez", "modal-title-set-up-public-access": "Nyilvános hozzáférés beállítása", @@ -11883,8 +11885,7 @@ "step-title-copy-url": "A nyilvános URL-cím kimásolása", "step-title-enable-feature-toggles": "Szükséges funkciókapcsolók engedélyezése", "step-title-start-ngrok": "Az ngrok elindítása az ideiglenes nyilvános hozzáféréshez", - "step-title-update-grafana-config": "Saját Grafana-konfiguráció frissítése", - "title-setting-connection-could-cause-temporary-outage": "A kapcsolat létrehozása ideiglenes üzemszünetet okozhat" + "step-title-update-grafana-config": "Saját Grafana-konfiguráció frissítése" }, "getting-started-page": { "subtitle-provisioning-feature": "Kiépítési kapcsolatok megtekintése és kezelése" @@ -11953,18 +11954,15 @@ }, "home-page": { "button-delete-repositories": "Adattárak törlése", - "configured-repositories-while-running-legacy-storage": "A konfigurált adattárak nem fognak működni az örökölt tárolás futtatása közben.", "confirm-delete-repositories": "Biztosan törli az összes konfigurált adattárat? A művelet nem vonható vissza.", "error-delete-all-repositories": "Az összes adattár törlése nem sikerült", - "remove-all-configured-repositories": "Az összes konfigurált adattár eltávolítása", "subtitle": "Konfigurált adattárak megtekintése és kezelése", "success-all-repositories-deleted": "Az összes konfigurált adattár törölve", "tab-getting-started": "Kezdés", "tab-getting-started-title": "Kezdés", "tab-repositories": "Adattárak", "tab-repositories-title": "Adattárak listája", - "title-delete-all-configured-repositories": "Az összes konfigurált adattár törlése", - "title-legacy-storage-detected": "Örökölt tárolás észlelve" + "title-delete-all-configured-repositories": "Az összes konfigurált adattár törlése" }, "http-utils": { "access-denied": "", @@ -12004,7 +12002,6 @@ "mode-options": { "disabled": { "folder-connected": "", - "legacy-storage": "", "not-allowed": "", "not-supported": "" }, @@ -12084,7 +12081,6 @@ "repository-status-page": { "back-to-repositories": "Vissza az adattárakhoz", "cleaning-up-resources": "Adattári erőforrások tisztítása", - "legacy-storage-message": "A példány még nem fut egyesített tárhellyel – áttelepítési varázslót igényel", "not-found": "nem található", "not-found-message": "Nem található adattár", "repository-config-exists-configuration": "Győződjön meg arról, hogy a tároló konfigurációja létezik a konfigurációs fájlban.", @@ -12093,7 +12089,6 @@ "tab-resources": "Erőforrások", "tab-resources-title": "", "title": "Adattár állapota", - "title-legacy-storage": "Örökölt tárolás", "title-queued-for-deletion": "Törlésre vár" }, "repository-type-cards": { @@ -12189,9 +12184,7 @@ "synchronize-step": { "repository-error": "", "repository-error-message": "", - "repository-unhealthy": "", - "synchronization-description": "Változtatások belefoglalása minden előzményértékhez", - "synchronization-options": "Szinkronizálási beállítások" + "repository-unhealthy": "" }, "token-permissions-info": { "and-click": "és kattintson erre:", @@ -12208,11 +12201,11 @@ }, "warning-title-default": "Figyelmeztetés", "wizard": { - "alert-point-1": "A folyamat során nem lehet erőforrásokat létrehozni, szerkeszteni vagy törölni. Az utolsó lépésben el fognak tűnni.", - "alert-point-2": "A kiépítés befejezése után az erőforrások újra megjelennek, és külső tárolóeszközön keresztül kezelhetők.", + "alert-point-1": "", + "alert-point-2": "", "alert-point-3": "A folyamat időtartama az érintett erőforrások számától függ.", - "alert-point-4": "A vállalati példányok adminisztrátorai megjeleníthetnek egy értesítési szalagot a felhasználóknak. A lépésenkénti utasításokat lásd az <2>útmutatóban.", - "alert-title": "Fontos: Egyetlen adat vagy konfiguráció sem fog elveszni, de az irányítópultok néhány percig átmenetileg nem lesznek elérhetők.", + "alert-point-4": "", + "alert-title": "", "button-cancel": "", "button-cancelling": "", "button-next": "Befejezés", @@ -12230,7 +12223,6 @@ "step-finish": "Válassza ki a további beállításokat", "step-synchronize": "Szinkronizálás külső tárolóval", "sync-description": "Erőforrások szinkronizálása külső tárolóval. Ezután az egyszeri lépés után az összes jövőbeli frissítés automatikusan mentve lesz az adattárba, és vissza lesz építve a példányba.", - "sync-option-history": "Előzmények", "title-bootstrap": "Válassza ki, mit szeretne szinkronizálni", "title-connect": "Csatlakozás külső tárolóhoz", "title-finish": "Válassza ki a további beállításokat", diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index 12e57299535..66ad5574fb6 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -3318,6 +3318,7 @@ "login-attribute-path-label": "Jalur atribut login", "login-prompt-consent": "Persetujuan", "login-prompt-description": "Menunjukkan jenis interaksi pengguna saat pengguna masuk dengan IdP.", + "login-prompt-description-google": "", "login-prompt-label": "Permintaan masuk", "login-prompt-login": "Masuk", "login-prompt-select-account": "Pilih akun", @@ -3375,6 +3376,7 @@ "use-pkce-description": "Jika diaktifkan, Grafana akan menggunakan <2>Proof Key for Code Exchange (PKCE) dengan Pemberian Kode Otorisasi OAuth2.", "use-pkce-label": "Gunakan PKCE", "use-refresh-token-description": "Jika diaktifkan, Grafana akan mengambil token akses baru menggunakan token muat ulang yang disediakan oleh penyedia OAuth2.", + "use-refresh-token-description-google": "", "use-refresh-token-label": "Gunakan token muat ulang", "validate-hosted-domain-description": "Jika diaktifkan, Grafana akan mencocokkan Domain yang Di-hosting yang diambil dari Token ID Google dengan daftar \"{{ allowedDomainsLabel }}\" yang ditentukan oleh pengguna.", "validate-hosted-domain-label": "Validasi domain yang di-hosting", @@ -10138,7 +10140,8 @@ "copy": "Salin pilihan", "disable-menu": "Nonaktifkan menu", "line-contains": "Tambahkan sebagai baris berisi filter", - "line-contains-not": "Tambahkan sebagai baris tidak berisi filter" + "line-contains-not": "Tambahkan sebagai baris tidak berisi filter", + "search-text": "" }, "show-log-attributes": "", "timestamp-format": "Resolusi stempel waktu", @@ -11820,7 +11823,6 @@ } }, "getting-started": { - "alert-temporary-outage": "Saat Anda menghubungkan seluruh instans, dasbor tidak akan tersedia saat menjalankan migrasi. Sebaiknya peringatkan pengguna Anda sebelum memulai proses.", "modal-description-public-access": "Siapkan akses publik ke instans Grafana Anda untuk mengaktifkan integrasi GitHub", "modal-description-required-features": "Aktifkan fitur Grafana yang diperlukan untuk penyediaan", "modal-title-set-up-public-access": "Atur akses publik", @@ -11832,8 +11834,7 @@ "step-title-copy-url": "Salin URL publik Anda", "step-title-enable-feature-toggles": "Aktifkan Tombol Fitur yang Diperlukan", "step-title-start-ngrok": "Mulai ngrok untuk akses publik sementara", - "step-title-update-grafana-config": "Perbarui konfigurasi Grafana Anda", - "title-setting-connection-could-cause-temporary-outage": "Mengatur koneksi ini dapat menyebabkan pemadaman sementara" + "step-title-update-grafana-config": "Perbarui konfigurasi Grafana Anda" }, "getting-started-page": { "subtitle-provisioning-feature": "Lihat dan kelola koneksi penyediaan Anda" @@ -11902,18 +11903,15 @@ }, "home-page": { "button-delete-repositories": "Hapus repositori", - "configured-repositories-while-running-legacy-storage": "Repositori yang dikonfigurasi tidak akan berfungsi saat menjalankan penyimpanan lama.", "confirm-delete-repositories": "Anda yakin ingin menghapus semua repositori yang dikonfigurasi? Tindakan ini tidak dapat dibatalkan.", "error-delete-all-repositories": "Gagal menghapus semua repositori", - "remove-all-configured-repositories": "Hapus semua repositori yang dikonfigurasi", "subtitle": "Lihat dan kelola repositori yang dikonfigurasi", "success-all-repositories-deleted": "Semua repositori yang dikonfigurasi dihapus", "tab-getting-started": "Memulai", "tab-getting-started-title": "Memulai", "tab-repositories": "Repositori", "tab-repositories-title": "Daftar repositori", - "title-delete-all-configured-repositories": "Hapus semua repositori yang dikonfigurasi", - "title-legacy-storage-detected": "Penyimpanan lama terdeteksi" + "title-delete-all-configured-repositories": "Hapus semua repositori yang dikonfigurasi" }, "http-utils": { "access-denied": "", @@ -11953,7 +11951,6 @@ "mode-options": { "disabled": { "folder-connected": "", - "legacy-storage": "", "not-allowed": "", "not-supported": "" }, @@ -12033,7 +12030,6 @@ "repository-status-page": { "back-to-repositories": "Kembali ke repositori", "cleaning-up-resources": "Membersihkan sumber daya repositori", - "legacy-storage-message": "Instans belum menjalankan penyimpanan terpadu -- memerlukan wizard migrasi", "not-found": "tidak ditemukan", "not-found-message": "Repositori tidak ditemukan", "repository-config-exists-configuration": "Pastikan konfigurasi repositori ada dalam file konfigurasi.", @@ -12042,7 +12038,6 @@ "tab-resources": "Sumber Daya", "tab-resources-title": "", "title": "Status Repositori", - "title-legacy-storage": "Penyimpanan Lama", "title-queued-for-deletion": "Diantrekan untuk dihapus" }, "repository-type-cards": { @@ -12138,9 +12133,7 @@ "synchronize-step": { "repository-error": "", "repository-error-message": "", - "repository-unhealthy": "", - "synchronization-description": "Sertakan commit untuk setiap nilai historis", - "synchronization-options": "Opsi sinkronisasi" + "repository-unhealthy": "" }, "token-permissions-info": { "and-click": "dan klik", @@ -12157,11 +12150,11 @@ }, "warning-title-default": "Peringatan", "wizard": { - "alert-point-1": "Sumber daya tidak akan dapat dibuat, diedit, atau dihapus selama proses ini. Pada langkah terakhir, sumber daya akan menghilang.", - "alert-point-2": "Setelah penyediaan selesai, sumber daya akan muncul kembali dan dikelola melalui penyimpanan eksternal.", + "alert-point-1": "", + "alert-point-2": "", "alert-point-3": "Durasi proses ini bergantung pada jumlah sumber daya yang terlibat.", - "alert-point-4": "Administrator instans Enterprise dapat menampilkan banner pengumuman kepada pengguna. Lihat <2>panduan ini untuk petunjuk langkah demi langkah.", - "alert-title": "Penting: Tidak ada data atau konfigurasi yang akan hilang, tetapi dasbor tidak akan tersedia sementara selama beberapa menit.", + "alert-point-4": "", + "alert-title": "", "button-cancel": "", "button-cancelling": "", "button-next": "Selesai", @@ -12179,7 +12172,6 @@ "step-finish": "Pilih pengaturan tambahan", "step-synchronize": "Sinkronkan dengan penyimpanan eksternal", "sync-description": "Sinkronkan sumber daya dengan penyimpanan eksternal. Setelah langkah satu kali ini, semua pembaruan mendatang akan disimpan secara otomatis ke repositori dan disediakan kembali ke instans.", - "sync-option-history": "Sejarah", "title-bootstrap": "Pilih item yang akan disinkronkan", "title-connect": "Hubungkan ke penyimpanan eksternal", "title-finish": "Pilih pengaturan tambahan", diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index c25f34fd338..76898afd674 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -3329,6 +3329,7 @@ "login-attribute-path-label": "Percorso attributo di accesso", "login-prompt-consent": "Consenso", "login-prompt-description": "Indica il tipo di interazione dell'utente quando accede con l'IdP.", + "login-prompt-description-google": "", "login-prompt-label": "Richiesta di accesso", "login-prompt-login": "Accedi", "login-prompt-select-account": "Seleziona account", @@ -3386,6 +3387,7 @@ "use-pkce-description": "Se abilitato, Grafana utilizzerà <2>Proof Key for Code Exchange (PKCE) con il codice di autorizzazione OAuth2.", "use-pkce-label": "Usa PKCE", "use-refresh-token-description": "Se abilitata, Grafana recupererà un nuovo token di accesso utilizzando il token di aggiornamento fornito dal provider OAuth2.", + "use-refresh-token-description-google": "", "use-refresh-token-label": "Usa token di aggiornamento", "validate-hosted-domain-description": "Se abilitata, Grafana abbinerà il dominio ospitato recuperato dal token ID Google all'elenco \"{{ allowedDomainsLabel }}\" specificato dall'utente.", "validate-hosted-domain-label": "Convalida dominio ospitato", @@ -10181,7 +10183,8 @@ "copy": "Copia la selezione", "disable-menu": "Disabilita menu", "line-contains": "Aggiungi come riga contenente filtro", - "line-contains-not": "Aggiungi come riga non contenente filtro" + "line-contains-not": "Aggiungi come riga non contenente filtro", + "search-text": "" }, "show-log-attributes": "", "timestamp-format": "Risoluzione marca temporale", @@ -11871,7 +11874,6 @@ } }, "getting-started": { - "alert-temporary-outage": "Quando colleghi l'intera istanza, le dashboard non saranno disponibili durante l'esecuzione della migrazione. Ti consigliamo di avvisare gli utenti prima di avviare il processo.", "modal-description-public-access": "Configura l'accesso pubblico alla tua istanza Grafana per abilitare l'integrazione con GitHub", "modal-description-required-features": "Abilita le funzionalità Grafana necessarie per il provisioning", "modal-title-set-up-public-access": "Configura l'accesso pubblico", @@ -11883,8 +11885,7 @@ "step-title-copy-url": "Copia il tuo URL pubblico", "step-title-enable-feature-toggles": "Abilita i toggle della funzionalità richieste", "step-title-start-ngrok": "Avvia ngrok per l'accesso pubblico temporaneo", - "step-title-update-grafana-config": "Aggiorna la configurazione di Grafana", - "title-setting-connection-could-cause-temporary-outage": "La configurazione di questa connessione potrebbe causare un'interruzione temporanea" + "step-title-update-grafana-config": "Aggiorna la configurazione di Grafana" }, "getting-started-page": { "subtitle-provisioning-feature": "Visualizza e gestisci le connessioni di provisioning" @@ -11953,18 +11954,15 @@ }, "home-page": { "button-delete-repositories": "Elimina repository", - "configured-repositories-while-running-legacy-storage": "I repository configurati non funzioneranno durante l'esecuzione della memoria esistente.", "confirm-delete-repositories": "Desideri davvero eliminare tutti i repository configurati? Quest'azione non può essere annullata.", "error-delete-all-repositories": "Impossibile eliminare tutti i repository", - "remove-all-configured-repositories": "Rimuovi tutti i repository configurati", "subtitle": "Visualizza e gestisci i repository configurati", "success-all-repositories-deleted": "Tutti i repository configurati sono stati eliminati", "tab-getting-started": "Come iniziare", "tab-getting-started-title": "Come iniziare", "tab-repositories": "Repository", "tab-repositories-title": "Elenco dei repository", - "title-delete-all-configured-repositories": "Elimina tutti i repository configurati", - "title-legacy-storage-detected": "Rilevata memoria esistente" + "title-delete-all-configured-repositories": "Elimina tutti i repository configurati" }, "http-utils": { "access-denied": "", @@ -12004,7 +12002,6 @@ "mode-options": { "disabled": { "folder-connected": "", - "legacy-storage": "", "not-allowed": "", "not-supported": "" }, @@ -12084,7 +12081,6 @@ "repository-status-page": { "back-to-repositories": "Torna ai repository", "cleaning-up-resources": "Pulizia delle risorse del repository", - "legacy-storage-message": "L'istanza non esegue ancora l'archiviazione unificata: richiede la procedura guidata di migrazione", "not-found": "non trovato", "not-found-message": "Repository non trovato", "repository-config-exists-configuration": "Assicurati che la configurazione del repository esista nel file di configurazione.", @@ -12093,7 +12089,6 @@ "tab-resources": "Risorse", "tab-resources-title": "", "title": "Stato del repository", - "title-legacy-storage": "Memoria esistente", "title-queued-for-deletion": "In coda per l'eliminazione" }, "repository-type-cards": { @@ -12189,9 +12184,7 @@ "synchronize-step": { "repository-error": "", "repository-error-message": "", - "repository-unhealthy": "", - "synchronization-description": "Includi commit per ogni valore storico", - "synchronization-options": "Opzioni di sincronizzazione" + "repository-unhealthy": "" }, "token-permissions-info": { "and-click": "e fare clic", @@ -12208,11 +12201,11 @@ }, "warning-title-default": "Attenzione", "wizard": { - "alert-point-1": "Le risorse non potranno essere create, modificate o eliminate durante questo processo. Nell'ultimo passaggio, scompariranno.", - "alert-point-2": "Una volta completato il provisioning, le risorse riappariranno e verranno gestite tramite la memoria esterna.", + "alert-point-1": "", + "alert-point-2": "", "alert-point-3": "La durata di questo processo dipende dal numero di risorse coinvolte.", - "alert-point-4": "Gli amministratori dell'istanza aziendale possono mostrare un banner di annuncio agli utenti. Consulta <2>questa guida per istruzioni dettagliate.", - "alert-title": "Importante: nessun dato o configurazione andrà perso, ma le dashboard non saranno disponibili per alcuni minuti.", + "alert-point-4": "", + "alert-title": "", "button-cancel": "", "button-cancelling": "", "button-next": "Fine", @@ -12230,7 +12223,6 @@ "step-finish": "Scegli impostazioni aggiuntive", "step-synchronize": "Sincronizza con la memoria esterna", "sync-description": "Sincronizza le risorse con la memoria esterna. Dopo questo passaggio una tantum, tutti gli aggiornamenti futuri verranno salvati automaticamente nel repository e ripristinati nell'istanza.", - "sync-option-history": "Cronologia", "title-bootstrap": "Scegli cosa sincronizzare", "title-connect": "Connetti a una memoria esterna", "title-finish": "Scegli impostazioni aggiuntive", diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index 528a0b4ac59..cebb4cd6bad 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -3318,6 +3318,7 @@ "login-attribute-path-label": "ログイン属性パス", "login-prompt-consent": "同意", "login-prompt-description": "ユーザーがアイデンティティプロバイダー (IdP) でログインするときのユーザーインタラクションのタイプを示します。", + "login-prompt-description-google": "", "login-prompt-label": "ログインプロンプト", "login-prompt-login": "ログイン", "login-prompt-select-account": "アカウントの選択", @@ -3375,6 +3376,7 @@ "use-pkce-description": "有効にすると、Grafanaは<2>OAuth2認証コード付与でコード交換用証明キー(PKCE)を使用します。", "use-pkce-label": "PKCEを使用", "use-refresh-token-description": "有効にすると、GrafanaはOAuth2プロバイダーから提供されたリフレッシュトークンを使用して新しいアクセストークンを取得します。", + "use-refresh-token-description-google": "", "use-refresh-token-label": "リフレッシュトークンを使用", "validate-hosted-domain-description": "有効にすると、GrafanaはGoogle IDトークンから取得したホストドメインをユーザーが指定した「{{ allowedDomainsLabel }}」リストと照合します。", "validate-hosted-domain-label": "ホストドメインを検証", @@ -10138,7 +10140,8 @@ "copy": "選択した部分をコピー", "disable-menu": "メニューを無効にする", "line-contains": "行にフィルタが含まれている場合はフィルタとして追加", - "line-contains-not": "フィルタが含まれていない行として追加" + "line-contains-not": "フィルタが含まれていない行として追加", + "search-text": "" }, "show-log-attributes": "", "timestamp-format": "タイムスタンプの表示形式", @@ -11820,7 +11823,6 @@ } }, "getting-started": { - "alert-temporary-outage": "インスタンス全体を接続すると、移行実行中はダッシュボードが利用できなくなります。このプロセスを開始する前に、ユーザーに警告することをお勧めします。", "modal-description-public-access": "GitHub連携を有効にするために、Grafanaインスタンスへの公開アクセスを設定", "modal-description-required-features": "プロビジョニングに必要なGrafana機能を有効化", "modal-title-set-up-public-access": "公開アクセスを設定", @@ -11832,8 +11834,7 @@ "step-title-copy-url": "公開URLをコピー", "step-title-enable-feature-toggles": "必要な機能トグルを有効化", "step-title-start-ngrok": "一時的な公開アクセス用にngrokを起動", - "step-title-update-grafana-config": "Grafana設定を更新", - "title-setting-connection-could-cause-temporary-outage": "この接続設定を行うことで、一時的に停止する可能性があります" + "step-title-update-grafana-config": "Grafana設定を更新" }, "getting-started-page": { "subtitle-provisioning-feature": "プロビジョニング接続を表示・管理" @@ -11902,18 +11903,15 @@ }, "home-page": { "button-delete-repositories": "リポジトリを削除", - "configured-repositories-while-running-legacy-storage": "レガシーストレージの実行中は、設定したリポジトリは機能しません。", "confirm-delete-repositories": "設定済みのリポジトリをすべて削除してもよろしいですか?この操作は元に戻せません。", "error-delete-all-repositories": "すべてのリポジトリの削除に失敗しました", - "remove-all-configured-repositories": "設定済みのリポジトリをすべて削除", "subtitle": "設定済みのリポジトリを表示・管理", "success-all-repositories-deleted": "設定済みのリポジトリがすべて削除されました", "tab-getting-started": "はじめに", "tab-getting-started-title": "はじめに", "tab-repositories": "リポジトリ", "tab-repositories-title": "リポジトリ一覧", - "title-delete-all-configured-repositories": "設定済みのリポジトリをすべて削除", - "title-legacy-storage-detected": "レガシーストレージが検出されました" + "title-delete-all-configured-repositories": "設定済みのリポジトリをすべて削除" }, "http-utils": { "access-denied": "", @@ -11953,7 +11951,6 @@ "mode-options": { "disabled": { "folder-connected": "", - "legacy-storage": "", "not-allowed": "", "not-supported": "" }, @@ -12033,7 +12030,6 @@ "repository-status-page": { "back-to-repositories": "リポジトリに戻る", "cleaning-up-resources": "リポジトリリソースのクリーンアップ中", - "legacy-storage-message": "インスタンスはまだ統合ストレージを実行していません - 移行ウィザードが必要です", "not-found": "見つかりません", "not-found-message": "リポジトリが見つかりません", "repository-config-exists-configuration": "リポジトリ設定が設定ファイルに含まれていることを確認してください。", @@ -12042,7 +12038,6 @@ "tab-resources": "リソース", "tab-resources-title": "", "title": "リポジトリの状態", - "title-legacy-storage": "レガシーストレージ", "title-queued-for-deletion": "削除待ちリストに追加されました" }, "repository-type-cards": { @@ -12138,9 +12133,7 @@ "synchronize-step": { "repository-error": "", "repository-error-message": "", - "repository-unhealthy": "", - "synchronization-description": "各履歴値のコミットを含める", - "synchronization-options": "同期オプション" + "repository-unhealthy": "" }, "token-permissions-info": { "and-click": "そしてクリック", @@ -12157,11 +12150,11 @@ }, "warning-title-default": "警告", "wizard": { - "alert-point-1": "このプロセス中はリソースの作成、編集、削除を行えません。最後のステップになると、リソースが消えます。", - "alert-point-2": "プロビジョニングが完了すると、リソースが再表示され、外部ストレージを通じて管理されます。", + "alert-point-1": "", + "alert-point-2": "", "alert-point-3": "このプロセスの所要時間は、関連するリソースの数によって異なります。", - "alert-point-4": "エンタープライズインスタンス管理者は、ユーザーにお知らせバナーを表示できます。詳しい手順については<2>このガイドをご覧ください。", - "alert-title": "重要:データや設定は失われませんが、ダッシュボードは数分間一時的に利用できなくなります。", + "alert-point-4": "", + "alert-title": "", "button-cancel": "", "button-cancelling": "", "button-next": "完了", @@ -12179,7 +12172,6 @@ "step-finish": "追加設定を選択", "step-synchronize": "外部ストレージと同期", "sync-description": "リソースを外部ストレージと同期します。この一度限りの手順が完了すると、その後のすべての更新は自動的にリポジトリに保存され、インスタンスにプロビジョニングされます。", - "sync-option-history": "履歴", "title-bootstrap": "同期する内容を選択", "title-connect": "外部ストレージに接続", "title-finish": "追加設定を選択", diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index 5b5dbf90754..07e410e67e1 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -3318,6 +3318,7 @@ "login-attribute-path-label": "로그인 속성 경로", "login-prompt-consent": "동의", "login-prompt-description": "사용자가 IdP로 로그인할 때 어떤 유형의 사용자 상호 작용이 있었는지 보여줍니다.", + "login-prompt-description-google": "", "login-prompt-label": "로그인 프롬프트", "login-prompt-login": "로그인", "login-prompt-select-account": "계정 선택", @@ -3375,6 +3376,7 @@ "use-pkce-description": "활성화되면 Grafana는 OAuth2 인증 코드 부여와 함께 <2>코드 교환을 위한 증명 키(PKCE)를 사용합니다.", "use-pkce-label": "PKCE 사용", "use-refresh-token-description": "활성화되면 Grafana는 OAuth2 제공자가 제공한 새로 고침 토큰을 사용하여 새 액세스 토큰을 가져옵니다.", + "use-refresh-token-description-google": "", "use-refresh-token-label": "새로 고침 토큰 사용", "validate-hosted-domain-description": "활성화되면 Grafana는 Google ID 토큰에서 검색한 호스팅된 도메인을 사용자가 지정한 \"{{ allowedDomainsLabel }}\" 목록과 일치시킵니다.", "validate-hosted-domain-label": "호스팅된 도메인 검증", @@ -10138,7 +10140,8 @@ "copy": "선택 항목 복사", "disable-menu": "메뉴 비활성화", "line-contains": "라인에 포함 필터로 추가", - "line-contains-not": "라인에 미포함 필터로 추가" + "line-contains-not": "라인에 미포함 필터로 추가", + "search-text": "" }, "show-log-attributes": "", "timestamp-format": "타임스탬프 해상도", @@ -11820,7 +11823,6 @@ } }, "getting-started": { - "alert-temporary-outage": "전체 인스턴스를 연결하면 마이그레이션를 실행하는 동안 대시보드를 사용할 수 없습니다. 프로세스를 시작하기 전에 사용자에게 경고하는 것이 좋습니다.", "modal-description-public-access": "Grafana 인스턴스에 대한 공개 액세스를 설정하여 GitHub 통합을 활성화합니다", "modal-description-required-features": "프로비저닝에 필요한 Grafana 기능 활성화", "modal-title-set-up-public-access": "공개 액세스 설정", @@ -11832,8 +11834,7 @@ "step-title-copy-url": "공개 URL 복사", "step-title-enable-feature-toggles": "필요한 기능 토글 활성화", "step-title-start-ngrok": "임시 공용 액세스를 위해 ngrok 시작", - "step-title-update-grafana-config": "Grafana 구성 업데이트", - "title-setting-connection-could-cause-temporary-outage": "이 연결을 설정하면 일시적인 중단이 발생할 수 있습니다" + "step-title-update-grafana-config": "Grafana 구성 업데이트" }, "getting-started-page": { "subtitle-provisioning-feature": "프로비저닝 연결 보기 및 관리" @@ -11902,18 +11903,15 @@ }, "home-page": { "button-delete-repositories": "리포지토리 삭제", - "configured-repositories-while-running-legacy-storage": "레거시 스토리지를 실행하는 동안에는 구성된 리포지토리가 작동하지 않습니다.", "confirm-delete-repositories": "정말 구성된 모든 리포지토리를 삭제하시겠어요? 처리 후에는 되돌릴 수 없습니다.", "error-delete-all-repositories": "모든 리포지토리 삭제 실패", - "remove-all-configured-repositories": "구성된 모든 리포지토리 제거", "subtitle": "구성된 리포지토리 보기 및 관리", "success-all-repositories-deleted": "구성된 모든 리포지토리가 삭제되었습니다", "tab-getting-started": "시작하기", "tab-getting-started-title": "시작하기", "tab-repositories": "리포지토리", "tab-repositories-title": "리포지토리 목록", - "title-delete-all-configured-repositories": "구성된 모든 리포지토리 삭제", - "title-legacy-storage-detected": "레거시 스토리지 감지됨" + "title-delete-all-configured-repositories": "구성된 모든 리포지토리 삭제" }, "http-utils": { "access-denied": "", @@ -11953,7 +11951,6 @@ "mode-options": { "disabled": { "folder-connected": "", - "legacy-storage": "", "not-allowed": "", "not-supported": "" }, @@ -12033,7 +12030,6 @@ "repository-status-page": { "back-to-repositories": "리포지토리로 돌아가기", "cleaning-up-resources": "리포지토리 리소스 정리 및 삭제 중", - "legacy-storage-message": "인스턴스가 아직 통합 스토리지를 실행하고 있지 않습니다. 마이그레이션 마법사가 필요합니다", "not-found": "찾을 수 없음", "not-found-message": "리포지토리를 찾을 수 없습니다", "repository-config-exists-configuration": "리포지토리 구성이 구성 파일에 있는지 확인하세요.", @@ -12042,7 +12038,6 @@ "tab-resources": "리소스", "tab-resources-title": "", "title": "리포지토리 상태", - "title-legacy-storage": "레거시 스토리지", "title-queued-for-deletion": "삭제 대기열에 추가됨" }, "repository-type-cards": { @@ -12138,9 +12133,7 @@ "synchronize-step": { "repository-error": "", "repository-error-message": "", - "repository-unhealthy": "", - "synchronization-description": "각 과거 값에 대한 커밋을 포함합니다", - "synchronization-options": "동기화 옵션" + "repository-unhealthy": "" }, "token-permissions-info": { "and-click": "및 클릭", @@ -12157,11 +12150,11 @@ }, "warning-title-default": "경고", "wizard": { - "alert-point-1": "이 프로세스 중에는 리소스를 생성, 편집 또는 삭제할 수 없습니다. 마지막 단계에서는 리소스가 표시되지 않습니다.", - "alert-point-2": "프로비저닝이 완료되면 리소스가 다시 표시되고 외부 스토리지를 통해 관리됩니다.", + "alert-point-1": "", + "alert-point-2": "", "alert-point-3": "이 프로세스의 지속 시간은 관련된 리소스 수에 따라 달라집니다.", - "alert-point-4": "Enterprise 인스턴스 관리자는 사용자에게 공지 배너를 표시할 수 있습니다. 단계별 지침은 <2>이 가이드를 확인하세요.", - "alert-title": "중요: 데이터나 구성은 손실되지 않지만 대시보드를 몇 분 동안 일시적으로 사용할 수 없습니다.", + "alert-point-4": "", + "alert-title": "", "button-cancel": "", "button-cancelling": "", "button-next": "완료", @@ -12179,7 +12172,6 @@ "step-finish": "추가 설정 선택", "step-synchronize": "외부 스토리지와 동기화", "sync-description": "외부 스토리지와 리소스를 동기화합니다. 이 일회성 단계 후에는 향후 모든 업데이트가 자동으로 리포지토리에 저장되고 인스턴스에 다시 프로비저닝됩니다.", - "sync-option-history": "이력", "title-bootstrap": "동기화할 항목 선택", "title-connect": "외부 스토리지에 연결", "title-finish": "추가 설정 선택", diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index 6040824f752..237fbb74953 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -3329,6 +3329,7 @@ "login-attribute-path-label": "Pad inlogattribuut", "login-prompt-consent": "Toestemming ", "login-prompt-description": "Geeft het type gebruikersinteractie aan wanneer de gebruiker inlogt met de IdP.", + "login-prompt-description-google": "", "login-prompt-label": "Login prompt", "login-prompt-login": "Inloggen", "login-prompt-select-account": "Selecteer account", @@ -3386,6 +3387,7 @@ "use-pkce-description": "Indien ingeschakeld, gebruikt Grafana <2>Proof Key for Code Exchange (PKCE) met de OAuth2-autorisatiecodetoewijzing.", "use-pkce-label": "PKCE gebruiken", "use-refresh-token-description": "Indien ingeschakeld, haalt Grafana een nieuw toegangstoken op met behulp van het vernieuwingstoken dat is verstrekt door de OAuth2-provider.", + "use-refresh-token-description-google": "", "use-refresh-token-label": "Vernieuwingstoken gebruiken", "validate-hosted-domain-description": "Indien ingeschakeld, zal Grafana het gehoste domein dat is opgehaald uit het Google id-token vergelijken met de '{{ allowedDomainsLabel }}' lijst die is opgegeven door de gebruiker.", "validate-hosted-domain-label": "Gehost domein valideren", @@ -10181,7 +10183,8 @@ "copy": "Selectie kopiëren", "disable-menu": "Menu uitschakelen", "line-contains": "Toevoegen als regel filter bevat", - "line-contains-not": "Toevoegen als regel geen filter bevat" + "line-contains-not": "Toevoegen als regel geen filter bevat", + "search-text": "" }, "show-log-attributes": "", "timestamp-format": "Tijdstempelresolutie", @@ -11871,7 +11874,6 @@ } }, "getting-started": { - "alert-temporary-outage": "Wanneer je je hele instantie koppelt, zijn er geen dashboards beschikbaar tijdens het uitvoeren van de migratie. We raden je aan je gebruikers te waarschuwen voordat je het proces start.", "modal-description-public-access": "Stel openbare toeging tot je Grafana-instantie in om GitHub-integratie in te schakelen", "modal-description-required-features": "Vereiste Grafana-functies inschakelen voor provisioning", "modal-title-set-up-public-access": "Openbare toegang instellen", @@ -11883,8 +11885,7 @@ "step-title-copy-url": "Kopieer je openbare URL", "step-title-enable-feature-toggles": "Schakel de vereiste functieschakelaars in", "step-title-start-ngrok": "Start ngrok voor tijdelijke openbare toegang", - "step-title-update-grafana-config": "Werk je Grafana-configuratie bij", - "title-setting-connection-could-cause-temporary-outage": "Het opzetten van deze verbinding kan een tijdelijke storing veroorzaken" + "step-title-update-grafana-config": "Werk je Grafana-configuratie bij" }, "getting-started-page": { "subtitle-provisioning-feature": "Je provisioningverbindingen bekijken en beheren" @@ -11953,18 +11954,15 @@ }, "home-page": { "button-delete-repositories": "Repositories verwijderen", - "configured-repositories-while-running-legacy-storage": "Geconfigureerde repositories werken niet tijdens het uitvoeren van legacy-opslag.", "confirm-delete-repositories": "Weet je zeker dat je alle geconfigureerde repositories wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.", "error-delete-all-repositories": "Kan niet alle repositories verwijderen", - "remove-all-configured-repositories": "Alle geconfigureerde repositories verwijderen", "subtitle": "Je geconfigureerde repositories bekijken en beheren", "success-all-repositories-deleted": "Alle geconfigureerde repositories zijn verwijderd", "tab-getting-started": "Aan de slag", "tab-getting-started-title": "Aan de slag", "tab-repositories": "Repositories", "tab-repositories-title": "Lijst van repositories", - "title-delete-all-configured-repositories": "Alle geconfigureerde repositories verwijderen", - "title-legacy-storage-detected": "Verouderde opslag gedetecteerd" + "title-delete-all-configured-repositories": "Alle geconfigureerde repositories verwijderen" }, "http-utils": { "access-denied": "", @@ -12004,7 +12002,6 @@ "mode-options": { "disabled": { "folder-connected": "", - "legacy-storage": "", "not-allowed": "", "not-supported": "" }, @@ -12084,7 +12081,6 @@ "repository-status-page": { "back-to-repositories": "Terug naar repositories", "cleaning-up-resources": "Bronnen van repository opschonen", - "legacy-storage-message": "Instantie draait nog geen geïntegreerde opslag -- migratiewizard vereist", "not-found": "niet gevonden", "not-found-message": "Repository niet gevonden", "repository-config-exists-configuration": "Zorg ervoor dat de repository-configuratie in het configuratiebestand bestaat.", @@ -12093,7 +12089,6 @@ "tab-resources": "Bronnen", "tab-resources-title": "", "title": "Repository-status", - "title-legacy-storage": "Legacy-opslag", "title-queued-for-deletion": "In de wachtrij voor verwijdering" }, "repository-type-cards": { @@ -12189,9 +12184,7 @@ "synchronize-step": { "repository-error": "", "repository-error-message": "", - "repository-unhealthy": "", - "synchronization-description": "Commits opnemen voor elke historische waarde", - "synchronization-options": "Synchronisatie-opties" + "repository-unhealthy": "" }, "token-permissions-info": { "and-click": "en klik", @@ -12208,11 +12201,11 @@ }, "warning-title-default": "Waarschuwing", "wizard": { - "alert-point-1": "Bronnen kunnen tijdens dit proces niet worden gemaakt, bewerkt of verwijderd. In de laatste stap verdwijnen ze.", - "alert-point-2": "Zodra de provisioning is voltooid, verschijnen bronnen weer en worden ze beheerd via externe opslag.", + "alert-point-1": "", + "alert-point-2": "", "alert-point-3": "De duur van dit proces is afhankelijk van het aantal betrokken bronnen.", - "alert-point-4": "Enterprise-instantiebeheerders kunnen een aankondigingsbanner weergeven aan gebruikers. Zie <2>deze handleiding voor stapsgewijze instructies.", - "alert-title": "Belangrijk: er zullen geen gegevens of configuratie verloren gaan, maar dashboards zullen tijdelijk een paar minuten niet beschikbaar zijn.", + "alert-point-4": "", + "alert-title": "", "button-cancel": "", "button-cancelling": "", "button-next": "Voltooien", @@ -12230,7 +12223,6 @@ "step-finish": "Kies aanvullende instellingen", "step-synchronize": "Synchroniseren met externe opslag", "sync-description": "Bronnen synchroniseren met externe opslag. Na deze eenmalige stap worden alle toekomstige updates automatisch opgeslagen in de repository en opnieuw ingericht in de instantie.", - "sync-option-history": "Geschiedenis", "title-bootstrap": "Kies wat je wilt synchroniseren", "title-connect": "Verbinden met externe opslag", "title-finish": "Kies aanvullende instellingen", diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index 91d924d3ca7..6ff40c3ab1f 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -3351,6 +3351,7 @@ "login-attribute-path-label": "Ścieżka atrybutu loginu", "login-prompt-consent": "Zgoda", "login-prompt-description": "Wskazuje rodzaj interakcji użytkownika, gdy użytkownik loguje się za pomocą IdP.", + "login-prompt-description-google": "", "login-prompt-label": "Monit o zalogowanie", "login-prompt-login": "Logowanie", "login-prompt-select-account": "Wybierz konto", @@ -3408,6 +3409,7 @@ "use-pkce-description": "Jeśli ta opcja jest włączona, Grafana będzie używać <2>klucza próbnego do wymiany kodów (PKCE) z nadawaniem kodu autoryzacyjnego OAuth2.", "use-pkce-label": "Użyj PKCE", "use-refresh-token-description": "Jeśli ta opcja jest włączona, Grafana pobiera nowy token dostępu za pomocą tokena odświeżania dostarczonego przez dostawcę OAuth2.", + "use-refresh-token-description-google": "", "use-refresh-token-label": "Użyj tokena odświeżania", "validate-hosted-domain-description": "Jeśli ta opcja jest włączona, Grafana dopasowuje hostowaną domenę pobraną z tokena Google ID do listy „{{ allowedDomainsLabel }}” określonej przez użytkownika.", "validate-hosted-domain-label": "Zweryfikuj hostowaną domenę", @@ -10267,7 +10269,8 @@ "copy": "Kopiuj zaznaczenie", "disable-menu": "Wyłącz menu", "line-contains": "Dodaj jako wiersz zawierający filtr", - "line-contains-not": "Dodaj jako wiersz nie zawierający filtra" + "line-contains-not": "Dodaj jako wiersz nie zawierający filtra", + "search-text": "" }, "show-log-attributes": "", "timestamp-format": "Rozdzielczość znacznika czasu", @@ -11973,7 +11976,6 @@ } }, "getting-started": { - "alert-temporary-outage": "Po połączeniu całej instancji pulpity będą niedostępne podczas uruchamiania migracji. Zalecamy ostrzeżenie użytkowników przed rozpoczęciem tego procesu.", "modal-description-public-access": "Skonfiguruj publiczny dostęp do instancji Grafany, aby włączyć integrację z GitHubem", "modal-description-required-features": "Włącz funkcje Grafany wymagane do aprowizacji", "modal-title-set-up-public-access": "Skonfiguruj dostęp publiczny", @@ -11985,8 +11987,7 @@ "step-title-copy-url": "Skopiuj swój publiczny URL", "step-title-enable-feature-toggles": "Włącz wymagane przełączniki funkcji", "step-title-start-ngrok": "Uruchom narzędzie ngrok, aby uzyskać tymczasowy dostęp publiczny", - "step-title-update-grafana-config": "Zaktualizuj konfigurację usługi Grafana", - "title-setting-connection-could-cause-temporary-outage": "Skonfigurowanie tego połączenia może spowodować tymczasową niedostępność" + "step-title-update-grafana-config": "Zaktualizuj konfigurację usługi Grafana" }, "getting-started-page": { "subtitle-provisioning-feature": "Wyświetlaj połączenia aprowizacyjne i nimi zarządzaj" @@ -12055,18 +12056,15 @@ }, "home-page": { "button-delete-repositories": "Usuń repozytoria", - "configured-repositories-while-running-legacy-storage": "Skonfigurowane repozytoria nie będą działać podczas uruchamiania starszej pamięci masowej.", "confirm-delete-repositories": "Czy na pewno chcesz usunąć wszystkie skonfigurowane repozytoria? To działanie jest nieodwracalne.", "error-delete-all-repositories": "Nie udało się usunąć wszystkich repozytoriów", - "remove-all-configured-repositories": "Usuń wszystkie skonfigurowane repozytoria", "subtitle": "Wyświetl skonfigurowane repozytoria i nimi zarządzaj", "success-all-repositories-deleted": "Wszystkie skonfigurowane repozytoria zostały usunięte", "tab-getting-started": "Pierwsze kroki", "tab-getting-started-title": "Pierwsze kroki", "tab-repositories": "Repozytoria", "tab-repositories-title": "Lista repozytoriów", - "title-delete-all-configured-repositories": "Usuń wszystkie skonfigurowane repozytoria", - "title-legacy-storage-detected": "Wykryto starszą pamięć masową" + "title-delete-all-configured-repositories": "Usuń wszystkie skonfigurowane repozytoria" }, "http-utils": { "access-denied": "", @@ -12106,7 +12104,6 @@ "mode-options": { "disabled": { "folder-connected": "", - "legacy-storage": "", "not-allowed": "", "not-supported": "" }, @@ -12186,7 +12183,6 @@ "repository-status-page": { "back-to-repositories": "Wróć do repozytoriów", "cleaning-up-resources": "Sprzątanie zasobów repozytorium", - "legacy-storage-message": "Instancja nie używa jeszcze ujednoliconej pamięci masowej – wymaga to kreatora migracji", "not-found": "nie znaleziono", "not-found-message": "Nie znaleziono repozytorium", "repository-config-exists-configuration": "Upewnij się, że konfiguracja repozytorium istnieje w pliku konfiguracyjnym.", @@ -12195,7 +12191,6 @@ "tab-resources": "Zasoby", "tab-resources-title": "", "title": "Status repozytorium", - "title-legacy-storage": "Starsza pamięć masowa", "title-queued-for-deletion": "Dodano do kolejki do usunięcia" }, "repository-type-cards": { @@ -12291,9 +12286,7 @@ "synchronize-step": { "repository-error": "", "repository-error-message": "", - "repository-unhealthy": "", - "synchronization-description": "Uwzględnij zatwierdzenia dla każdej wartości historycznej", - "synchronization-options": "Opcje synchronizacji" + "repository-unhealthy": "" }, "token-permissions-info": { "and-click": "i kliknij", @@ -12310,11 +12303,11 @@ }, "warning-title-default": "Ostrzeżenie", "wizard": { - "alert-point-1": "Podczas tego procesu nie będzie można tworzyć, edytować ani usuwać zasobów. W ostatnim kroku zasoby znikną.", - "alert-point-2": "Po zakończeniu aprowizacji zasoby pojawią się ponownie i będą zarządzane za pośrednictwem zewnętrznej pamięci masowej.", + "alert-point-1": "", + "alert-point-2": "", "alert-point-3": "Czas trwania tego procesu zależy od liczby zasobów.", - "alert-point-4": "Administratorzy instancji Enterprise mogą włączyć wyświetlanie użytkownikom banera z ogłoszeniem. Szczegółowe instrukcje znajdziesz w <2>tym przewodniku.", - "alert-title": "Ważne: dane ani konfiguracja nie zostaną utracone, ale pulpity będą niedostępne przez kilka minut.", + "alert-point-4": "", + "alert-title": "", "button-cancel": "", "button-cancelling": "", "button-next": "Zakończ", @@ -12332,7 +12325,6 @@ "step-finish": "Wybierz dodatkowe ustawienia", "step-synchronize": "Synchronizuj z zewnętrzną pamięcią masową", "sync-description": "Zsynchronizuj zasoby z zewnętrzną pamięcią masową. Po tym jednorazowym kroku wszystkie przyszłe aktualizacje zostaną automatycznie zapisane w repozytorium i ponownie aprowizowane w instancji.", - "sync-option-history": "Historia", "title-bootstrap": "Wybierz, co chcesz zsynchronizować", "title-connect": "Połączenie z zewnętrzną pamięcią masową", "title-finish": "Wybierz dodatkowe ustawienia", diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index 0a59679f4a8..cbaee920be0 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -3329,6 +3329,7 @@ "login-attribute-path-label": "Caminho do atributo de login", "login-prompt-consent": "Consentimento ", "login-prompt-description": "Indica o tipo de interação do usuário quando o usuário faz login com o IdP.", + "login-prompt-description-google": "", "login-prompt-label": "Solicitação de login", "login-prompt-login": "Login", "login-prompt-select-account": "Selecionar conta", @@ -3386,6 +3387,7 @@ "use-pkce-description": "Se estiver habilitada, a Grafana usará a <2>Chave de prova para troca de código (PKCE) com a concessão de código de autorização OAuth2.", "use-pkce-label": "Usar PKCE", "use-refresh-token-description": "Se estiver habilitado, o Grafana buscará um novo token de acesso usando o token de atualização fornecido pelo provedor OAuth2.", + "use-refresh-token-description-google": "", "use-refresh-token-label": "Usar token de atualização", "validate-hosted-domain-description": "Se estiver habilitado, o Grafana realizará a comparação entre o Domínio Hospedado obtido a partir do token de ID do Google e a lista \"{{ allowedDomainsLabel }}\" especificada pelo usuário.", "validate-hosted-domain-label": "Validar domínio hospedado", @@ -10181,7 +10183,8 @@ "copy": "Copiar seleção", "disable-menu": "Desativar menu", "line-contains": "Adicionar como linha contém filtro", - "line-contains-not": "Adicionar como linha não contém filtro" + "line-contains-not": "Adicionar como linha não contém filtro", + "search-text": "" }, "show-log-attributes": "", "timestamp-format": "Resolução de data e hora", @@ -11871,7 +11874,6 @@ } }, "getting-started": { - "alert-temporary-outage": "Quando você conectar toda a sua instância, os painéis ficarão indisponíveis durante a execução da migração. Recomendamos que você avise a seus usuários antes de iniciar o processo.", "modal-description-public-access": "Configure o acesso público à sua instância da Grafana para ativar a integração do GitHub", "modal-description-required-features": "Ativar os recursos necessários da Grafana para provisionamento", "modal-title-set-up-public-access": "Configurar acesso público", @@ -11883,8 +11885,7 @@ "step-title-copy-url": "Copie sua URL pública", "step-title-enable-feature-toggles": "Ativar as opções dos recursos necessários", "step-title-start-ngrok": "Inicie o ngrok para acesso público temporário", - "step-title-update-grafana-config": "Atualize sua configuração da Grafana", - "title-setting-connection-could-cause-temporary-outage": "Estabelecer esta conexão pode causar uma interrupção temporária" + "step-title-update-grafana-config": "Atualize sua configuração da Grafana" }, "getting-started-page": { "subtitle-provisioning-feature": "Visualize e gerencie suas conexões de provisionamento" @@ -11953,18 +11954,15 @@ }, "home-page": { "button-delete-repositories": "Excluir repositórios", - "configured-repositories-while-running-legacy-storage": "Os repositórios configurados não funcionarão durante a execução do armazenamento legado.", "confirm-delete-repositories": "Tem certeza de que deseja excluir todos os repositórios configurados? Esta ação não poderá ser desfeita. ", "error-delete-all-repositories": "Falha ao excluir todos os repositórios", - "remove-all-configured-repositories": "Remover todos os repositórios configurados", "subtitle": "Visualize e gerencie seus repositórios configurados", "success-all-repositories-deleted": "Todos os repositórios configurados foram excluídos", "tab-getting-started": "Como começar", "tab-getting-started-title": "Como começar", "tab-repositories": "Repositórios", "tab-repositories-title": "Lista de repositórios", - "title-delete-all-configured-repositories": "Excluir todos os repositórios configurados", - "title-legacy-storage-detected": "Armazenamento legado detectado" + "title-delete-all-configured-repositories": "Excluir todos os repositórios configurados" }, "http-utils": { "access-denied": "", @@ -12004,7 +12002,6 @@ "mode-options": { "disabled": { "folder-connected": "", - "legacy-storage": "", "not-allowed": "", "not-supported": "" }, @@ -12084,7 +12081,6 @@ "repository-status-page": { "back-to-repositories": "Voltar para os repositórios", "cleaning-up-resources": "Limpando recursos do repositório", - "legacy-storage-message": "A instância ainda não está executando o armazenamento unificado: requer um assistente de migração", "not-found": "não encontrado", "not-found-message": "Repositório não encontrado", "repository-config-exists-configuration": "Verifique se a configuração do repositório existe no arquivo de configuração.", @@ -12093,7 +12089,6 @@ "tab-resources": "Fontes", "tab-resources-title": "", "title": "Status do repositório", - "title-legacy-storage": "Armazenamento legado", "title-queued-for-deletion": "Na fila para exclusão" }, "repository-type-cards": { @@ -12189,9 +12184,7 @@ "synchronize-step": { "repository-error": "", "repository-error-message": "", - "repository-unhealthy": "", - "synchronization-description": "Incluir confirmações para cada valor histórico", - "synchronization-options": "Opções de sincronização" + "repository-unhealthy": "" }, "token-permissions-info": { "and-click": "e clique", @@ -12208,11 +12201,11 @@ }, "warning-title-default": "Aviso", "wizard": { - "alert-point-1": "Os recursos não poderão ser criados, editados ou excluídos durante este processo. Na última etapa, eles desaparecerão.", - "alert-point-2": "Assim que o provisionamento for concluído, os recursos reaparecerão e serão gerenciados por meio de armazenamento externo.", + "alert-point-1": "", + "alert-point-2": "", "alert-point-3": "A duração deste processo depende da quantidade de recursos envolvidos.", - "alert-point-4": "Os administradores de instâncias corporativas podem exibir um banner de anúncio para os usuários. Consulte <2>este guia para conferir instruções passo a passo.", - "alert-title": "Importante: nenhum dado ou configuração será perdido, mas os painéis ficarão indisponíveis por alguns minutos.", + "alert-point-4": "", + "alert-title": "", "button-cancel": "", "button-cancelling": "", "button-next": "Finalizar", @@ -12230,7 +12223,6 @@ "step-finish": "Escolha configurações adicionais", "step-synchronize": "Sincronizar com armazenamento externo", "sync-description": "Sincronize recursos com armazenamento externo. Após esta única etapa, todas as atualizações futuras serão salvas automaticamente no repositório e provisionadas de volta para a instância.", - "sync-option-history": "Histórico", "title-bootstrap": "Escolha o que será sincronizado", "title-connect": "Conectar ao armazenamento externo", "title-finish": "Escolha configurações adicionais", diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index fa614655ae0..4e99ebc24f7 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -3329,6 +3329,7 @@ "login-attribute-path-label": "Caminho do atributo de início de sessão", "login-prompt-consent": "Consentimento", "login-prompt-description": "Indica o tipo de interação do utilizador quando o utilizador inicia sessão com o IdP.", + "login-prompt-description-google": "", "login-prompt-label": "Instrução de início de sessão", "login-prompt-login": "Entrar", "login-prompt-select-account": "Selecione a conta", @@ -3386,6 +3387,7 @@ "use-pkce-description": "Se estiver ativado, a Grafana utilizará a <2>Chave de prova para troca de código (PKCE) com a concessão do código de autorização OAuth2.", "use-pkce-label": "Utilizar PKCE", "use-refresh-token-description": "Se estiver ativado, o Grafana irá obter um novo token de acesso ao utilizar o token de atualização fornecido pelo fornecedor OAuth2.", + "use-refresh-token-description-google": "", "use-refresh-token-label": "Utilizar token de atualização", "validate-hosted-domain-description": "Se estiver ativado, o Grafana irá comparar o domínio alojado obtido do token de ID do Google com a lista \"{{ allowedDomainsLabel }}\" especificada pelo utilizador.", "validate-hosted-domain-label": "Validar domínio alojado", @@ -10181,7 +10183,8 @@ "copy": "Copiar seleção", "disable-menu": "Desativar menu", "line-contains": "Adicionar, pois a linha contém filtro", - "line-contains-not": "Adicionar, pois a linha não contém filtro" + "line-contains-not": "Adicionar, pois a linha não contém filtro", + "search-text": "" }, "show-log-attributes": "", "timestamp-format": "Resolução do carimbo de data e hora", @@ -11871,7 +11874,6 @@ } }, "getting-started": { - "alert-temporary-outage": "Quando liga toda a sua instância, os painéis de controlo ficarão indisponíveis durante a execução da migração. Recomendamos que avise os seus utilizadores antes de iniciar o processo.", "modal-description-public-access": "Configure o acesso público à sua instância Grafana para ativar a integração do GitHub", "modal-description-required-features": "Ativar as funcionalidades necessárias da Grafana para aprovisionamento", "modal-title-set-up-public-access": "Configurar acesso público", @@ -11883,8 +11885,7 @@ "step-title-copy-url": "Copie o seu URL público", "step-title-enable-feature-toggles": "Ativar alternâncias de funcionalidades necessárias", "step-title-start-ngrok": "Inicie a ngrok para um acesso público temporário", - "step-title-update-grafana-config": "Atualizar a sua configuração da Grafana", - "title-setting-connection-could-cause-temporary-outage": "Configurar esta ligação pode causar uma interrupção temporária" + "step-title-update-grafana-config": "Atualizar a sua configuração da Grafana" }, "getting-started-page": { "subtitle-provisioning-feature": "Ver e gerir as suas ligações de provisionamento" @@ -11953,18 +11954,15 @@ }, "home-page": { "button-delete-repositories": "Eliminar repositórios", - "configured-repositories-while-running-legacy-storage": "Os repositórios configurados não funcionarão durante a execução do armazenamento herdado.", "confirm-delete-repositories": "Tem a certeza de que pretende eliminar todos os repositórios configurados? Esta ação não pode ser anulada. ", "error-delete-all-repositories": "Não foi possível eliminar todos os repositórios", - "remove-all-configured-repositories": "Remover todos os repositórios configurados", "subtitle": "Ver e gerir os seus repositórios configurados", "success-all-repositories-deleted": "Todos os repositórios configurados eliminados", "tab-getting-started": "Introdução", "tab-getting-started-title": "Introdução", "tab-repositories": "Repositórios", "tab-repositories-title": "Lista de repositórios", - "title-delete-all-configured-repositories": "Eliminar todos os repositórios configurados", - "title-legacy-storage-detected": "Armazenamento herdado detetado" + "title-delete-all-configured-repositories": "Eliminar todos os repositórios configurados" }, "http-utils": { "access-denied": "", @@ -12004,7 +12002,6 @@ "mode-options": { "disabled": { "folder-connected": "", - "legacy-storage": "", "not-allowed": "", "not-supported": "" }, @@ -12084,7 +12081,6 @@ "repository-status-page": { "back-to-repositories": "Voltar aos repositórios", "cleaning-up-resources": "A limpar recursos do repositório", - "legacy-storage-message": "A instância ainda não está a executar o armazenamento unificado; requer o assistente de migração", "not-found": "não encontrado", "not-found-message": "Repositório não encontrado", "repository-config-exists-configuration": "Certifique-se de que a configuração do repositório existe no ficheiro de configuração.", @@ -12093,7 +12089,6 @@ "tab-resources": "Recursos", "tab-resources-title": "", "title": "Estado do repositório", - "title-legacy-storage": "Armazenamento herdado", "title-queued-for-deletion": "Em fila para eliminação" }, "repository-type-cards": { @@ -12189,9 +12184,7 @@ "synchronize-step": { "repository-error": "", "repository-error-message": "", - "repository-unhealthy": "", - "synchronization-description": "Incluir commits para cada valor histórico", - "synchronization-options": "Opções de sincronização" + "repository-unhealthy": "" }, "token-permissions-info": { "and-click": "e clique em", @@ -12208,11 +12201,11 @@ }, "warning-title-default": "Aviso", "wizard": { - "alert-point-1": "Os recursos não poderão ser criados, editados ou eliminados durante este processo. No último passo, irão desaparecer.", - "alert-point-2": "Assim que o provisionamento estiver concluído, os recursos reaparecerão e serão geridos através de um armazenamento externo.", + "alert-point-1": "", + "alert-point-2": "", "alert-point-3": "A duração deste processo depende do número de recursos envolvidos.", - "alert-point-4": "Os administradores de instâncias empresariais podem exibir um banner de anúncio aos utilizadores. Consulte <2>este guia para obter instruções passo a passo.", - "alert-title": "Importante: não serão perdidos dados ou configurações, mas os painéis de controlo ficarão temporariamente indisponíveis durante alguns minutos.", + "alert-point-4": "", + "alert-title": "", "button-cancel": "", "button-cancelling": "", "button-next": "Concluir", @@ -12230,7 +12223,6 @@ "step-finish": "Escolha as definições adicionais", "step-synchronize": "Sincronizar com armazenamento externo", "sync-description": "Sincronize recursos com um armazenamento externo. Após este passo único, todas as atualizações futuras serão guardadas automaticamente no repositório e aprovisionadas novamente para a instância.", - "sync-option-history": "Histórico", "title-bootstrap": "Escolha o que sincronizar", "title-connect": "Ligar a um armazenamento externo", "title-finish": "Escolha as definições adicionais", diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index 667bcac393c..3d29410a1bf 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -3351,6 +3351,7 @@ "login-attribute-path-label": "Путь атрибута логина", "login-prompt-consent": "Согласие", "login-prompt-description": "Указывает тип взаимодействия с пользователем при входе с помощью IdP.", + "login-prompt-description-google": "", "login-prompt-label": "Приглашение на вход в систему", "login-prompt-login": "Вход", "login-prompt-select-account": "Выберите учетную запись", @@ -3408,6 +3409,7 @@ "use-pkce-description": "Если этот параметр включен, Grafana будет использовать <2>ключ подтверждения для обмена кодом (PKCE) с грантом кода авторизации OAuth2.", "use-pkce-label": "Использовать PKCE", "use-refresh-token-description": "Если этот параметр включен, Grafana будет получать новый токен доступа с помощью токена обновления, предоставленного поставщиком OAuth2.", + "use-refresh-token-description-google": "", "use-refresh-token-label": "Использовать токен обновления", "validate-hosted-domain-description": "Если этот параметр включен, Grafana будет сопоставлять домен размещения, полученный из токена Google ID, с заданным пользователем списком «{{ allowedDomainsLabel }}».", "validate-hosted-domain-label": "Проверять домен размещения", @@ -10267,7 +10269,8 @@ "copy": "Копировать выделение", "disable-menu": "Отключить меню", "line-contains": "Добавить, поскольку строка содержит фильтр", - "line-contains-not": "Добавить, поскольку строка не содержит фильтр" + "line-contains-not": "Добавить, поскольку строка не содержит фильтр", + "search-text": "" }, "show-log-attributes": "", "timestamp-format": "Разрешение метки времени", @@ -11973,7 +11976,6 @@ } }, "getting-started": { - "alert-temporary-outage": "Если подключается весь экземпляр, дашборды будут недоступны во время выполнения переноса. Рекомендуем предупредить пользователей перед запуском процесса.", "modal-description-public-access": "Настройте общий доступ к своему экземпляру Grafana, чтобы включить интеграцию с GitHub.", "modal-description-required-features": "Включите необходимые функции Grafana для подготовки.", "modal-title-set-up-public-access": "Настройка общего доступа", @@ -11985,8 +11987,7 @@ "step-title-copy-url": "Копирование общедоступного URL-адреса", "step-title-enable-feature-toggles": "Включение необходимых переключателей функциональности", "step-title-start-ngrok": "Запуск ngrok для временного общего доступа", - "step-title-update-grafana-config": "Обновление конфигурации Grafana", - "title-setting-connection-could-cause-temporary-outage": "Настройка этого подключения может привести к временному сбою" + "step-title-update-grafana-config": "Обновление конфигурации Grafana" }, "getting-started-page": { "subtitle-provisioning-feature": "Просмотр подключений для подготовки и управлением ими" @@ -12055,18 +12056,15 @@ }, "home-page": { "button-delete-repositories": "Удалить репозитории", - "configured-repositories-while-running-legacy-storage": "Настроенные репозитории не будут работать при использовании устаревшего хранилища.", "confirm-delete-repositories": "Действительно удалить все настроенные репозитории? Это действие невозможно отменить.", "error-delete-all-repositories": "Не удалось удалить все репозитории", - "remove-all-configured-repositories": "Удалить все настроенные репозитории", "subtitle": "Просмотр настроенных репозиториев и управление ими", "success-all-repositories-deleted": "Все настроенные репозитории удалены", "tab-getting-started": "Начало работы", "tab-getting-started-title": "Начало работы", "tab-repositories": "Репозитории", "tab-repositories-title": "Список репозиториев", - "title-delete-all-configured-repositories": "Удаление всех настроенных репозиториев", - "title-legacy-storage-detected": "Обнаружено устаревшее хранилище" + "title-delete-all-configured-repositories": "Удаление всех настроенных репозиториев" }, "http-utils": { "access-denied": "", @@ -12106,7 +12104,6 @@ "mode-options": { "disabled": { "folder-connected": "", - "legacy-storage": "", "not-allowed": "", "not-supported": "" }, @@ -12186,7 +12183,6 @@ "repository-status-page": { "back-to-repositories": "Назад к репозиториям", "cleaning-up-resources": "Очистка ресурсов репозитория", - "legacy-storage-message": "Экземпляр пока не использует унифицированное хранилище — требуется мастер переноса", "not-found": "не найдено", "not-found-message": "Репозиторий не найден", "repository-config-exists-configuration": "Убедитесь, что конфигурация репозитория существует в файле конфигурации.", @@ -12195,7 +12191,6 @@ "tab-resources": "Ресурсы", "tab-resources-title": "", "title": "Состояние репозитория", - "title-legacy-storage": "Устаревшее хранилище", "title-queued-for-deletion": "В очереди на удаление" }, "repository-type-cards": { @@ -12291,9 +12286,7 @@ "synchronize-step": { "repository-error": "", "repository-error-message": "", - "repository-unhealthy": "", - "synchronization-description": "Включить фиксации для каждого ранее зафиксированного значения", - "synchronization-options": "Параметры синхронизации" + "repository-unhealthy": "" }, "token-permissions-info": { "and-click": "и нажать", @@ -12310,11 +12303,11 @@ }, "warning-title-default": "Предупреждение", "wizard": { - "alert-point-1": "Во время этого процесса невозможно создавать, редактировать или удалять ресурсы. На последнем шаге они исчезнут.", - "alert-point-2": "После завершения подготовки ресурсы снова появятся и будут управляться через внешнее хранилище.", + "alert-point-1": "", + "alert-point-2": "", "alert-point-3": "Длительность процесса зависит от количества задействованных ресурсов.", - "alert-point-4": "Администраторы экземпляров Enterprise могут показывать пользователям баннер с объявлением. Пошаговые инструкции см. в <2>руководстве.", - "alert-title": "Важно. Данные и конфигурация не будут потеряны, однако дашборды будут недоступны в течение нескольких минут.", + "alert-point-4": "", + "alert-title": "", "button-cancel": "", "button-cancelling": "", "button-next": "Готово", @@ -12332,7 +12325,6 @@ "step-finish": "Выберите дополнительные параметры", "step-synchronize": "Синхронизировать с внешним хранилищем", "sync-description": "Синхронизируйте ресурсы с внешним хранилищем. После этого разового шага все будущие обновления будут автоматически сохраняться в репозитории и загружаться обратно в экземпляр.", - "sync-option-history": "История", "title-bootstrap": "Выберите, что синхронизировать", "title-connect": "Подключение к внешнему хранилищу", "title-finish": "Выбор дополнительных параметров", diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index e37fcfe4df4..38943db06ed 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -3329,6 +3329,7 @@ "login-attribute-path-label": "Sökväg för inloggningsattribut", "login-prompt-consent": "Samtycke", "login-prompt-description": "Anger typen av användarinteraktion när användaren loggar in med IdP.", + "login-prompt-description-google": "", "login-prompt-label": "Inloggningsprompt", "login-prompt-login": "Logga in", "login-prompt-select-account": "Välj konto", @@ -3386,6 +3387,7 @@ "use-pkce-description": "Om det är aktiverat kommer Grafana att använda <2>Proof Key for Code Exchange (PKCE) med OAuth2 Authorization Code Grant.", "use-pkce-label": "Använd PKCE", "use-refresh-token-description": "Om det är aktiverat hämtar Grafana en ny åtkomsttoken med hjälp av den uppdateringstoken som tillhandahålls av OAuth2-leverantören.", + "use-refresh-token-description-google": "", "use-refresh-token-label": "Använd uppdateringstoken", "validate-hosted-domain-description": "Om det är aktiverat kontrollerar Grafana den värddomän som hämtas från Googles ID-token mot den ”{{ allowedDomainsLabel }}”-lista som användaren har specificerat.", "validate-hosted-domain-label": "Validera värddomänen", @@ -10181,7 +10183,8 @@ "copy": "Kopiera markerat", "disable-menu": "Inaktivera meny", "line-contains": "Lägg till som rad innehåller filter", - "line-contains-not": "Lägg till som rad innehåller inte filter" + "line-contains-not": "Lägg till som rad innehåller inte filter", + "search-text": "" }, "show-log-attributes": "", "timestamp-format": "Tidsstämpelupplösning", @@ -11871,7 +11874,6 @@ } }, "getting-started": { - "alert-temporary-outage": "När du ansluter hela din instans kommer instrumentpaneler inte att vara tillgängliga när du kör migreringen. Vi rekommenderar att du varnar dina användare innan du påbörjar processen.", "modal-description-public-access": "Konfigurera offentlig åtkomst till din Grafana-instans för att aktivera GitHub-integrering", "modal-description-required-features": "Aktivera obligatoriska Grafana-funktioner för provisionering", "modal-title-set-up-public-access": "Konfigurera offentlig åtkomst", @@ -11883,8 +11885,7 @@ "step-title-copy-url": "Kopiera din offentliga webbadress", "step-title-enable-feature-toggles": "Aktivera obligatoriska funktionsväxlar", "step-title-start-ngrok": "Starta ngrok för tillfällig offentlig åtkomst", - "step-title-update-grafana-config": "Uppdatera din Grafana-konfiguration", - "title-setting-connection-could-cause-temporary-outage": "Konfiguration av den här anslutningen kan orsaka ett tillfälligt avbrott" + "step-title-update-grafana-config": "Uppdatera din Grafana-konfiguration" }, "getting-started-page": { "subtitle-provisioning-feature": "Visa och hantera dina provisioneringsanslutningar" @@ -11953,18 +11954,15 @@ }, "home-page": { "button-delete-repositories": "Radera databaser", - "configured-repositories-while-running-legacy-storage": "Konfigurerade lagringsplatser fungerar inte när äldre lagring används.", "confirm-delete-repositories": "Är du säker på att du vill radera alla konfigurerade databaser? Den här åtgärden kan inte ångras.", "error-delete-all-repositories": "Det gick inte att radera alla databaser", - "remove-all-configured-repositories": "Ta bort alla konfigurerade databaser", "subtitle": "Visa och hantera dina konfigurerade databaser", "success-all-repositories-deleted": "Alla konfigurerade databaser har raderats", "tab-getting-started": "Kom igång", "tab-getting-started-title": "Kom igång", "tab-repositories": "Lagringsplatser", "tab-repositories-title": "Lista över lagringsplatser", - "title-delete-all-configured-repositories": "Radera alla konfigurerade databaser", - "title-legacy-storage-detected": "Äldre lagring upptäcktes" + "title-delete-all-configured-repositories": "Radera alla konfigurerade databaser" }, "http-utils": { "access-denied": "", @@ -12004,7 +12002,6 @@ "mode-options": { "disabled": { "folder-connected": "", - "legacy-storage": "", "not-allowed": "", "not-supported": "" }, @@ -12084,7 +12081,6 @@ "repository-status-page": { "back-to-repositories": "Tillbaka till lagringsplatserna", "cleaning-up-resources": "Rensa lagringsplatsresurser", - "legacy-storage-message": "Instansen kör ännu inte enhetlig lagring – kräver migreringsguide", "not-found": "hittades inte", "not-found-message": "Lagringsplatsen hittades inte", "repository-config-exists-configuration": "Verifiera att lagringsplatskonfigurationen finns i konfigurationsfilen.", @@ -12093,7 +12089,6 @@ "tab-resources": "Resurser", "tab-resources-title": "", "title": "Status för lagringsplats", - "title-legacy-storage": "Äldre lagring", "title-queued-for-deletion": "Köad för radering" }, "repository-type-cards": { @@ -12189,9 +12184,7 @@ "synchronize-step": { "repository-error": "", "repository-error-message": "", - "repository-unhealthy": "", - "synchronization-description": "Inkludera åtaganden för varje historiskt värde", - "synchronization-options": "Synkroniseringsalternativ" + "repository-unhealthy": "" }, "token-permissions-info": { "and-click": "och klicka på", @@ -12208,11 +12201,11 @@ }, "warning-title-default": "Varning", "wizard": { - "alert-point-1": "Resurser kan inte skapas, redigeras eller raderas under den här processen. I det sista steget kommer de att försvinna.", - "alert-point-2": "När provisionering är klar kommer resurserna att visas igen och kan hanteras via extern lagring.", + "alert-point-1": "", + "alert-point-2": "", "alert-point-3": "Hur lång den här processen är beror på hur många resurser som är inblandade.", - "alert-point-4": "Enterprise-instansadministratörer kan visa en meddelandebanner för användare. Se <2>denna guide för steg för steg-anvisningar.", - "alert-title": "Viktigt: Inga data eller konfigurationer kommer att förloras, men instrumentpaneler kommer att vara tillfälligt otillgängliga under några minuter.", + "alert-point-4": "", + "alert-title": "", "button-cancel": "", "button-cancelling": "", "button-next": "Slutför", @@ -12230,7 +12223,6 @@ "step-finish": "Välj ytterligare inställningar", "step-synchronize": "Synkronisera med extern lagring", "sync-description": "Synkronisera resurser med extern lagring. Efter det här engångssteget sparas alla framtida uppdateringar automatiskt på lagringsplatsen och provisioneras tillbaka till instansen.", - "sync-option-history": "Historik", "title-bootstrap": "Välj vad du vill synkronisera", "title-connect": "Anslut till extern lagring", "title-finish": "Välj ytterligare inställningar", diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index d2cbb01804d..07a0b6673ac 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -3329,6 +3329,7 @@ "login-attribute-path-label": "Giriş özniteliği yolu", "login-prompt-consent": "Onay", "login-prompt-description": "Kullanıcı IdP ile oturum açtığında kullanıcı etkileşiminin türünü gösterir.", + "login-prompt-description-google": "", "login-prompt-label": "Oturum açma istemi", "login-prompt-login": "Oturum aç", "login-prompt-select-account": "Hesap seç", @@ -3386,6 +3387,7 @@ "use-pkce-description": "Etkinleştirilirse Grafana OAuth2 Yetkilendirme Kodu Verilmesi ile birlikte <2>Proof Key for Code Exchange (PKCE) kullanacaktır.", "use-pkce-label": "PKCE kullan", "use-refresh-token-description": "Etkinleştirilirse Grafana, OAuth2 sağlayıcısı tarafından sağlanan yenileme belirtecini kullanarak yeni bir erişim belirteci alır.", + "use-refresh-token-description-google": "", "use-refresh-token-label": "Yenileme belirteci kullan", "validate-hosted-domain-description": "Etkinleştirilirse Grafana, Google Kimlik Belirtecinden alınan Barındırılan Alan Adı ile kullanıcı tarafından belirtilen \"{{ allowedDomainsLabel }}\" listesini eşleştirir.", "validate-hosted-domain-label": "Barındırılan alan adını doğrula", @@ -10181,7 +10183,8 @@ "copy": "Seçimi kopyala", "disable-menu": "Menüyü devre dışı bırak", "line-contains": "Satır içerir filtresi olarak ekle", - "line-contains-not": "Satır içermez filtresi olarak ekle" + "line-contains-not": "Satır içermez filtresi olarak ekle", + "search-text": "" }, "show-log-attributes": "", "timestamp-format": "", @@ -11871,7 +11874,6 @@ } }, "getting-started": { - "alert-temporary-outage": "Tüm örneğinizi bağladığınızda geçiş işlemi sırasında panolar kullanılamaz hale gelir. İşleme başlamadan önce kullanıcılarınızı bilgilendirmenizi öneririz.", "modal-description-public-access": "GitHub entegrasyonu için Grafana örneğinizde genel erişimi ayarlayın", "modal-description-required-features": "Sağlama işlemleri için gerekli Grafana özelliklerini etkinleştir", "modal-title-set-up-public-access": "Herkese açık erişimi ayarla", @@ -11883,8 +11885,7 @@ "step-title-copy-url": "Herkese açık URL'nizi kopyalayın", "step-title-enable-feature-toggles": "Gerekli Özellik Geçişlerini Etkinleştir", "step-title-start-ngrok": "Geçici genel erişim için ngrok'u başlat", - "step-title-update-grafana-config": "Grafana yapılandırmanızı güncelleyin", - "title-setting-connection-could-cause-temporary-outage": "Bu bağlantıyı kurmak geçici bir kesintiye neden olabilir" + "step-title-update-grafana-config": "Grafana yapılandırmanızı güncelleyin" }, "getting-started-page": { "subtitle-provisioning-feature": "Sağlama bağlantılarınızı görüntüleyin ve yönetin" @@ -11953,18 +11954,15 @@ }, "home-page": { "button-delete-repositories": "Depoları sil", - "configured-repositories-while-running-legacy-storage": "Eski depolama kullanılırken yapılandırılmış depolar çalışmaz.", "confirm-delete-repositories": "Yapılandırılmış tüm depoları silmek istediğinizden emin misiniz? Bu işlem geri alınamaz. ", "error-delete-all-repositories": "Tüm depolar silinemedi", - "remove-all-configured-repositories": "Yapılandırılmış tüm depoları kaldır", "subtitle": "Yapılandırılmış depoları görüntüleyin ve yönetin", "success-all-repositories-deleted": "Yapılandırılmış tüm depolar silindi", "tab-getting-started": "Başlarken", "tab-getting-started-title": "Başlarken", "tab-repositories": "Depolar", "tab-repositories-title": "Depo listesi", - "title-delete-all-configured-repositories": "Yapılandırılmış tüm depoları sil", - "title-legacy-storage-detected": "Eski depolama algılandı" + "title-delete-all-configured-repositories": "Yapılandırılmış tüm depoları sil" }, "http-utils": { "access-denied": "", @@ -12004,7 +12002,6 @@ "mode-options": { "disabled": { "folder-connected": "", - "legacy-storage": "", "not-allowed": "", "not-supported": "" }, @@ -12084,7 +12081,6 @@ "repository-status-page": { "back-to-repositories": "Depolara geri dön", "cleaning-up-resources": "Depo kaynakları temizleniyor", - "legacy-storage-message": "Örnek henüz birleşik depolamayı kullanmıyor: Geçiş sihirbazı gerekiyor", "not-found": "bulunamadı", "not-found-message": "Depo bulunamadı", "repository-config-exists-configuration": "Depo yapılandırmasının yapılandırma dosyasında mevcut olduğundan emin olun.", @@ -12093,7 +12089,6 @@ "tab-resources": "Kaynaklar", "tab-resources-title": "", "title": "Depo Durumu", - "title-legacy-storage": "Eski Depolama", "title-queued-for-deletion": "Silinmek üzere kuyruğa alındı" }, "repository-type-cards": { @@ -12189,9 +12184,7 @@ "synchronize-step": { "repository-error": "", "repository-error-message": "", - "repository-unhealthy": "", - "synchronization-description": "Her geçmiş değer için yürütmeyi dahil et", - "synchronization-options": "Senkronizasyon seçenekleri" + "repository-unhealthy": "" }, "token-permissions-info": { "and-click": "ve şuna tıklayın:", @@ -12208,11 +12201,11 @@ }, "warning-title-default": "Uyarı", "wizard": { - "alert-point-1": "Bu işlem sırasında kaynaklar oluşturulamayacak, düzenlenemeyecek veya silinemeyecek. Son adımda kaynaklar kaybolacaktır.", - "alert-point-2": "Sağlama işlemi tamamlandığında kaynaklar tekrar görünecek ve harici depolama üzerinden yönetilecektir.", + "alert-point-1": "", + "alert-point-2": "", "alert-point-3": "Bu işlemin süresi dâhil olan kaynakların sayısına bağlıdır.", - "alert-point-4": "Enterprise örnek yöneticileri, kullanıcılara bir duyuru afişi gösterebilir. Adım adım talimatlar için <2>bu kılavuza bakın.", - "alert-title": "Önemli: Veri veya yapılandırma kaybı yaşanmayacak ancak panolar birkaç dakika boyunca geçici olarak kullanılamayacaktır.", + "alert-point-4": "", + "alert-title": "", "button-cancel": "", "button-cancelling": "", "button-next": "Sonlandır", @@ -12230,7 +12223,6 @@ "step-finish": "Ek ayarlar seçin", "step-synchronize": "Harici depolama ile senkronize et", "sync-description": "Kaynakları harici depolama ile senkronize edin. Bu tek seferlik adımdan sonra tüm gelecekteki güncellemeler otomatik olarak depoya kaydedilecek ve örneğe geri sağlanacaktır.", - "sync-option-history": "Geçmiş", "title-bootstrap": "Nelerin senkronize edileceğini seçin", "title-connect": "Harici depolamaya bağlan", "title-finish": "Ek ayarlar seçin", diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index 1b08b386268..a1eb5ea1fae 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -3318,6 +3318,7 @@ "login-attribute-path-label": "登录属性路径", "login-prompt-consent": "同意", "login-prompt-description": "指示用户使用 IdP 登录时的用户交互类型。", + "login-prompt-description-google": "", "login-prompt-label": "登录提示", "login-prompt-login": "登录", "login-prompt-select-account": "选择账户", @@ -3375,6 +3376,7 @@ "use-pkce-description": "如果启用,Grafana 将使用<2>代码交换的证明密钥 (PKCE) 与 OAuth2 身份验证代码授予。", "use-pkce-label": "使用 PKCE", "use-refresh-token-description": "如果启用,Grafana 将使用 OAuth2 提供程序提供的刷新令牌获取新的访问令牌。", + "use-refresh-token-description-google": "", "use-refresh-token-label": "使用刷新令牌", "validate-hosted-domain-description": "如果启用,Grafana 将把从 Google ID 令牌检索到的托管域与用户指定的“{{ allowedDomainsLabel }}”列表进行匹配。", "validate-hosted-domain-label": "验证托管域", @@ -10138,7 +10140,8 @@ "copy": "复制选择", "disable-menu": "禁用菜单", "line-contains": "添加为包含筛选器的行", - "line-contains-not": "添加为不包含筛选器的行" + "line-contains-not": "添加为不包含筛选器的行", + "search-text": "" }, "show-log-attributes": "", "timestamp-format": "时间戳分辨率", @@ -11820,7 +11823,6 @@ } }, "getting-started": { - "alert-temporary-outage": "在连接整个实例的情况下,数据面板在运行迁移时将不可用。我们建议您在开始此流程之前提醒您的用户。", "modal-description-public-access": "设置对您 Grafana 实例的公共访问以启用 GitHub 集成", "modal-description-required-features": "启用预配所需的 Grafana 功能", "modal-title-set-up-public-access": "设置公共访问", @@ -11832,8 +11834,7 @@ "step-title-copy-url": "复制您的公共 URL", "step-title-enable-feature-toggles": "启用所需的功能切换", "step-title-start-ngrok": "启动 ngrok 以实现临时公共访问", - "step-title-update-grafana-config": "更新您的 Grafana 配置", - "title-setting-connection-could-cause-temporary-outage": "设置此连接可能会导致暂时中断" + "step-title-update-grafana-config": "更新您的 Grafana 配置" }, "getting-started-page": { "subtitle-provisioning-feature": "查看和管理您的预配连接" @@ -11902,18 +11903,15 @@ }, "home-page": { "button-delete-repositories": "删除存储库", - "configured-repositories-while-running-legacy-storage": "运行传统存储时,已配置的存储库将不起作用。", "confirm-delete-repositories": "您确定要删除所有已配置的存储库吗?此操作无法撤销。", "error-delete-all-repositories": "删除所有存储库失败", - "remove-all-configured-repositories": "移除所有已配置的存储库", "subtitle": "查看和管理已配置的存储库", "success-all-repositories-deleted": "已删除所有已配置的存储库", "tab-getting-started": "开始操作", "tab-getting-started-title": "开始操作", "tab-repositories": "存储库", "tab-repositories-title": "存储库列表", - "title-delete-all-configured-repositories": "删除所有已配置的存储库", - "title-legacy-storage-detected": "检测到传统存储" + "title-delete-all-configured-repositories": "删除所有已配置的存储库" }, "http-utils": { "access-denied": "", @@ -11953,7 +11951,6 @@ "mode-options": { "disabled": { "folder-connected": "", - "legacy-storage": "", "not-allowed": "", "not-supported": "" }, @@ -12033,7 +12030,6 @@ "repository-status-page": { "back-to-repositories": "回到存储库", "cleaning-up-resources": "清理存储库资源", - "legacy-storage-message": "实例尚未运行统一存储 -- 需要迁移向导", "not-found": "找不到", "not-found-message": "找不到存储库", "repository-config-exists-configuration": "确保存储库配置存在于配置文件中。", @@ -12042,7 +12038,6 @@ "tab-resources": "资源", "tab-resources-title": "", "title": "存储库状态", - "title-legacy-storage": "传统存储", "title-queued-for-deletion": "已进入队列等待删除" }, "repository-type-cards": { @@ -12138,9 +12133,7 @@ "synchronize-step": { "repository-error": "", "repository-error-message": "", - "repository-unhealthy": "", - "synchronization-description": "包括每个历史值的提交", - "synchronization-options": "同步选项" + "repository-unhealthy": "" }, "token-permissions-info": { "and-click": "并点击", @@ -12157,11 +12150,11 @@ }, "warning-title-default": "警告", "wizard": { - "alert-point-1": "在此过程中将无法创建、编辑或删除资源。在最后一步中,它们将消失。", - "alert-point-2": "预配完成后,资源将重新出现并通过外部存储进行管理。", + "alert-point-1": "", + "alert-point-2": "", "alert-point-3": "此过程的持续时间取决于所涉及资源的数量。", - "alert-point-4": "企业实例管理员可以向用户显示公告横幅。有关分步说明,请参阅<2>本指南。", - "alert-title": "重要提示:数据或配置不会丢失,但数据面板将在几分钟内暂时不可用。", + "alert-point-4": "", + "alert-title": "", "button-cancel": "", "button-cancelling": "", "button-next": "完成", @@ -12179,7 +12172,6 @@ "step-finish": "选择附加设置", "step-synchronize": "与外部存储同步", "sync-description": "将资源与外部存储同步。完成此一次性步骤后,所有后续更新都将自动保存到存储库中,并预配回实例。", - "sync-option-history": "历史记录", "title-bootstrap": "选择要同步的内容", "title-connect": "连接到外部存储", "title-finish": "选择附加设置", diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index ba9708811e3..df0a64f1c1e 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -3318,6 +3318,7 @@ "login-attribute-path-label": "登入屬性路徑", "login-prompt-consent": "同意", "login-prompt-description": "表示使用者使用 IdP 登入時的使用者互動類型。", + "login-prompt-description-google": "", "login-prompt-label": "登入提示", "login-prompt-login": "登入", "login-prompt-select-account": "選擇帳戶", @@ -3375,6 +3376,7 @@ "use-pkce-description": "若啟用,Grafana 將使用<2>代碼交換的驗證金鑰 (PKCE) 與 OAuth2 授權碼授權。", "use-pkce-label": "使用 PKCE", "use-refresh-token-description": "若啟用,Grafana 將使用 OAuth2 提供者提供的重新整理權杖來擷取新的存取權杖。", + "use-refresh-token-description-google": "", "use-refresh-token-label": "使用重新整理權杖", "validate-hosted-domain-description": "若啟用,Grafana 將會比對從 Google ID 權杖擷取的託管網域與使用者指定的「{{ allowedDomainsLabel }}」清單。", "validate-hosted-domain-label": "驗證託管網域", @@ -10138,7 +10140,8 @@ "copy": "複製選取範圍", "disable-menu": "停用功能表", "line-contains": "新增為包含篩選條件的行", - "line-contains-not": "新增為不包含篩選條件的行" + "line-contains-not": "新增為不包含篩選條件的行", + "search-text": "" }, "show-log-attributes": "", "timestamp-format": "時間戳記解決方案", @@ -11820,7 +11823,6 @@ } }, "getting-started": { - "alert-temporary-outage": "連接整個執行個體時,在執行移轉時將無法使用儀表板。建議您在開始此流程前通知使用者。", "modal-description-public-access": "設定 Grafana 執行個體的公共存取權限,以啟用 GitHub 整合", "modal-description-required-features": "啟用所需的 Grafana 功能以進行佈建", "modal-title-set-up-public-access": "設定公共存取", @@ -11832,8 +11834,7 @@ "step-title-copy-url": "複製您的公共網址", "step-title-enable-feature-toggles": "啟用必要的功能切換", "step-title-start-ngrok": "啟動 ngrok 以進行臨時公共存取", - "step-title-update-grafana-config": "更新您的 Grafana 設定", - "title-setting-connection-could-cause-temporary-outage": "設定此連線可能會導致暫時中斷" + "step-title-update-grafana-config": "更新您的 Grafana 設定" }, "getting-started-page": { "subtitle-provisioning-feature": "檢視及管理您的佈建連線" @@ -11902,18 +11903,15 @@ }, "home-page": { "button-delete-repositories": "刪除儲存庫", - "configured-repositories-while-running-legacy-storage": "在執行舊的儲存空間時,已設定的儲存庫將不作用。", "confirm-delete-repositories": "確定要刪除所有已設定的儲存庫嗎?此動作無法還原。", "error-delete-all-repositories": "無法刪除所有儲存庫", - "remove-all-configured-repositories": "移除所有已設定的儲存庫", "subtitle": "檢視及管理您設定的儲存庫", "success-all-repositories-deleted": "已刪除所有已設定的儲存庫", "tab-getting-started": "立即開始", "tab-getting-started-title": "立即開始", "tab-repositories": "儲存庫", "tab-repositories-title": "儲存庫清單", - "title-delete-all-configured-repositories": "刪除所有已設定的儲存庫", - "title-legacy-storage-detected": "偵測到舊的儲存空間" + "title-delete-all-configured-repositories": "刪除所有已設定的儲存庫" }, "http-utils": { "access-denied": "", @@ -11953,7 +11951,6 @@ "mode-options": { "disabled": { "folder-connected": "", - "legacy-storage": "", "not-allowed": "", "not-supported": "" }, @@ -12033,7 +12030,6 @@ "repository-status-page": { "back-to-repositories": "返回至儲存庫", "cleaning-up-resources": "清理儲存庫資源", - "legacy-storage-message": "執行個體尚未執行統一儲存空間 -- 需要移轉小工具", "not-found": "找不到", "not-found-message": "找不到儲存庫", "repository-config-exists-configuration": "請確認儲存庫設定存在於設定檔案中。", @@ -12042,7 +12038,6 @@ "tab-resources": "資源", "tab-resources-title": "", "title": "儲存庫狀態", - "title-legacy-storage": "舊版儲存空間", "title-queued-for-deletion": "已排入刪除佇列" }, "repository-type-cards": { @@ -12138,9 +12133,7 @@ "synchronize-step": { "repository-error": "", "repository-error-message": "", - "repository-unhealthy": "", - "synchronization-description": "包含每個歷史數值的提交", - "synchronization-options": "同步選項" + "repository-unhealthy": "" }, "token-permissions-info": { "and-click": "然後按一下", @@ -12157,11 +12150,11 @@ }, "warning-title-default": "警告", "wizard": { - "alert-point-1": "在此過程中,將無法建立、編輯或刪除資源。在最後一步中,它們將會消失。", - "alert-point-2": "佈建完成後,資源將重新出現,並透過外部儲存空間進行管理。", + "alert-point-1": "", + "alert-point-2": "", "alert-point-3": "此流程的持續時間取決於所涉及的資源數量。", - "alert-point-4": "企業執行個體管理員可以向使用者顯示公告橫幅。請參閱<2>本指南,了解逐步說明。", - "alert-title": "重要事項:不會遺失任何資料或設定,但儀表板將在幾分鐘內暫時無法使用。", + "alert-point-4": "", + "alert-title": "", "button-cancel": "", "button-cancelling": "", "button-next": "結束", @@ -12179,7 +12172,6 @@ "step-finish": "選擇附加設定", "step-synchronize": "與外部儲存空間同步", "sync-description": "將資源與外部儲存空間同步。在此一次性步驟之後,未來的所有更新都將自動儲存到儲存庫中,並佈建回執行個體。", - "sync-option-history": "歷史紀錄", "title-bootstrap": "選擇要同步的內容", "title-connect": "連接到外部儲存空間", "title-finish": "選擇附加設定", From 7572acf380e5e80138c440256d19b6e6ec24ed8e Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 18 Dec 2025 07:44:33 +0100 Subject: [PATCH 003/229] E2E Selectors: Fix comment typo (#115528) fix typo --- packages/grafana-e2e-selectors/src/types/selectors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/grafana-e2e-selectors/src/types/selectors.ts b/packages/grafana-e2e-selectors/src/types/selectors.ts index 036d6f36d4d..dbce9c93bd7 100644 --- a/packages/grafana-e2e-selectors/src/types/selectors.ts +++ b/packages/grafana-e2e-selectors/src/types/selectors.ts @@ -15,7 +15,7 @@ export type FunctionSelector = (id: string) => string; export type FunctionSelectorTwoArgs = (arg1: string, arg2: string) => string; /** - * A function selector without argument + * A function selector without arguments */ export type CssSelector = () => string; From d9d39ae17817bf53f966cc4690f6a4f1d2d06d4c Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 18 Dec 2025 09:56:46 +0100 Subject: [PATCH 004/229] E2E Selectors: Add missing comma (#115531) add missing comma --- packages/grafana-e2e-selectors/src/selectors/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/grafana-e2e-selectors/src/selectors/index.ts b/packages/grafana-e2e-selectors/src/selectors/index.ts index bfe90848026..3abef8119dd 100644 --- a/packages/grafana-e2e-selectors/src/selectors/index.ts +++ b/packages/grafana-e2e-selectors/src/selectors/index.ts @@ -9,7 +9,7 @@ const Components = resolveSelectors(versionedComponents); const selectors = { pages: Pages, components: Components }; /** - * Exposes Pages, Component selectors and E2ESelectors type in package for easy use in e2e tests and in production code + * Exposes Pages, Component selectors, and E2ESelectors type in package for easy use in e2e tests and in production code. */ export { Pages, From 5e3a1091b351a63d2b4339f621c2f69e4a0f3336 Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Thu, 18 Dec 2025 10:06:39 +0100 Subject: [PATCH 005/229] Alerting: Add a feature toggle to fetch rules with compact=true (#115533) --- .../grafana-data/src/types/featureToggles.gen.ts | 4 ++++ pkg/services/featuremgmt/registry.go | 7 +++++++ pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 ++++ pkg/services/featuremgmt/toggles_gen.json | 13 +++++++++++++ 5 files changed, 29 insertions(+) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 51eada8c120..6ce08b92452 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -828,6 +828,10 @@ export interface FeatureToggles { */ fetchRulesUsingPost?: boolean; /** + * Add compact=true when fetching rules + */ + fetchRulesInCompactMode?: boolean; + /** * Enables the new logs panel * @default true */ diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index b0beaa05482..af82c05e61d 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1360,6 +1360,13 @@ var ( Owner: grafanaAlertingSquad, HideFromDocs: true, }, + { + Name: "fetchRulesInCompactMode", + Description: "Add compact=true when fetching rules", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + HideFromDocs: true, + }, { Name: "newLogsPanel", Description: "Enables the new logs panel", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 46581205ae4..fedcd197a1f 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -188,6 +188,7 @@ grafanaAdvisor,privatePreview,@grafana/plugins-platform-backend,false,false,fals elasticsearchImprovedParsing,experimental,@grafana/partner-datasources,false,false,false datasourceConnectionsTab,privatePreview,@grafana/plugins-platform-backend,false,false,true fetchRulesUsingPost,experimental,@grafana/alerting-squad,false,false,false +fetchRulesInCompactMode,experimental,@grafana/alerting-squad,false,false,false newLogsPanel,GA,@grafana/observability-logs,false,false,true grafanaconThemes,GA,@grafana/grafana-frontend-platform,false,true,false alertingJiraIntegration,experimental,@grafana/alerting-squad,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index fc7b8043dec..020e0bce893 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -571,6 +571,10 @@ const ( // Use a POST request to list rules by passing down the namespaces user has access to FlagFetchRulesUsingPost = "fetchRulesUsingPost" + // FlagFetchRulesInCompactMode + // Add compact=true when fetching rules + FlagFetchRulesInCompactMode = "fetchRulesInCompactMode" + // FlagGrafanaconThemes // Enables the temporary themes for GrafanaCon FlagGrafanaconThemes = "grafanaconThemes" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 4cc98de091b..3c33f362b29 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1457,6 +1457,19 @@ "expression": "false" } }, + { + "metadata": { + "name": "fetchRulesInCompactMode", + "resourceVersion": "1766045288124", + "creationTimestamp": "2025-12-18T08:08:08Z" + }, + "spec": { + "description": "Add compact=true when fetching rules", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "hideFromDocs": true + } + }, { "metadata": { "name": "fetchRulesUsingPost", From a2a278a52eb79b54fae615899cc748db67e8a589 Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Thu, 18 Dec 2025 10:12:31 +0100 Subject: [PATCH 006/229] Schema V2: Always set unique refid for queries in conversion V1 -> V2 (#115534) Always set unique refid in conversion --- .../v1beta1.panel-target-missing-refid.json | 156 ++++++++++++ ...1.panel-target-missing-refid.v0alpha1.json | 161 ++++++++++++ ...1.panel-target-missing-refid.v2alpha1.json | 227 +++++++++++++++++ ...a1.panel-target-missing-refid.v2beta1.json | 231 ++++++++++++++++++ .../conversion/v1beta1_to_v2alpha1.go | 43 ++++ .../conversion/v1beta1_to_v2alpha1_test.go | 42 ++++ 6 files changed, 860 insertions(+) create mode 100644 apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.panel-target-missing-refid.json create mode 100644 apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.panel-target-missing-refid.v0alpha1.json create mode 100644 apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.panel-target-missing-refid.v2alpha1.json create mode 100644 apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.panel-target-missing-refid.v2beta1.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.panel-target-missing-refid.json b/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.panel-target-missing-refid.json new file mode 100644 index 00000000000..65bf0b136a6 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.panel-target-missing-refid.json @@ -0,0 +1,156 @@ +{ + "apiVersion": "dashboard.grafana.app/v1beta1", + "kind": "Dashboard", + "metadata": { + "name": "ad5vfcn", + "namespace": "default", + "uid": "dlMZZl6GndU8gJLUQSmgZxXBPCNXyXhNBeQJhHXl0r4X", + "resourceVersion": "2", + "generation": 2, + "creationTimestamp": "2025-11-28T10:14:21Z", + "labels": { + "grafana.app/deprecatedInternalID": "288" + }, + "annotations": { + "grafana.app/createdBy": "user:eex2ofwuj0agwd", + "grafana.app/updatedBy": "user:eex2ofwuj0agwd", + "grafana.app/updatedTimestamp": "2025-11-28T10:15:06Z" + } + }, + "spec": { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 288, + "links": [], + "panels": [ + { + "datasource": { + "type": "testdata", + "uid": "gdev-testdata" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0-pre", + "targets": [ + { + "datasource": {}, + "queryType": "randomWalk" + }, + { + "datasource": {}, + "queryType": "randomWalk" + } + ], + "title": "New panel", + "type": "timeseries" + } + ], + "preload": false, + "schemaVersion": 42, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Panel ds inheritance ", + "uid": "ad5vfcn", + "version": 2 + }, + "status": {} +} diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.panel-target-missing-refid.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.panel-target-missing-refid.v0alpha1.json new file mode 100644 index 00000000000..ad063633f86 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.panel-target-missing-refid.v0alpha1.json @@ -0,0 +1,161 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v0alpha1", + "metadata": { + "name": "ad5vfcn", + "namespace": "default", + "uid": "dlMZZl6GndU8gJLUQSmgZxXBPCNXyXhNBeQJhHXl0r4X", + "resourceVersion": "2", + "generation": 2, + "creationTimestamp": "2025-11-28T10:14:21Z", + "labels": { + "grafana.app/deprecatedInternalID": "288" + }, + "annotations": { + "grafana.app/createdBy": "user:eex2ofwuj0agwd", + "grafana.app/updatedBy": "user:eex2ofwuj0agwd", + "grafana.app/updatedTimestamp": "2025-11-28T10:15:06Z" + } + }, + "spec": { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 288, + "links": [], + "panels": [ + { + "datasource": { + "type": "testdata", + "uid": "gdev-testdata" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0-pre", + "targets": [ + { + "datasource": {}, + "queryType": "randomWalk" + }, + { + "datasource": {}, + "queryType": "randomWalk" + } + ], + "title": "New panel", + "type": "timeseries" + } + ], + "preload": false, + "schemaVersion": 42, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Panel ds inheritance ", + "uid": "ad5vfcn", + "version": 2 + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.panel-target-missing-refid.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.panel-target-missing-refid.v2alpha1.json new file mode 100644 index 00000000000..a8ce2b6a42b --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.panel-target-missing-refid.v2alpha1.json @@ -0,0 +1,227 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v2alpha1", + "metadata": { + "name": "ad5vfcn", + "namespace": "default", + "uid": "dlMZZl6GndU8gJLUQSmgZxXBPCNXyXhNBeQJhHXl0r4X", + "resourceVersion": "2", + "generation": 2, + "creationTimestamp": "2025-11-28T10:14:21Z", + "labels": { + "grafana.app/deprecatedInternalID": "288" + }, + "annotations": { + "grafana.app/createdBy": "user:eex2ofwuj0agwd", + "grafana.app/updatedBy": "user:eex2ofwuj0agwd", + "grafana.app/updatedTimestamp": "2025-11-28T10:15:06Z" + } + }, + "spec": { + "annotations": [ + { + "kind": "AnnotationQuery", + "spec": { + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "query": { + "kind": "grafana", + "spec": {} + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "builtIn": true, + "legacyOptions": { + "type": "dashboard" + } + } + } + ], + "cursorSync": "Off", + "editable": true, + "elements": { + "panel-1": { + "kind": "Panel", + "spec": { + "id": 1, + "title": "New panel", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "testdata", + "spec": { + "queryType": "randomWalk" + } + }, + "datasource": { + "type": "testdata", + "uid": "gdev-testdata" + }, + "refId": "A", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "testdata", + "spec": { + "queryType": "randomWalk" + } + }, + "datasource": { + "type": "testdata", + "uid": "gdev-testdata" + }, + "refId": "B", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "timeseries", + "spec": { + "pluginVersion": "12.4.0-pre", + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + } + } + } + } + } + }, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 0, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-1" + } + } + } + ] + } + }, + "links": [], + "liveNow": false, + "preload": false, + "tags": [], + "timeSettings": { + "timezone": "browser", + "from": "now-6h", + "to": "now", + "autoRefresh": "", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "hideTimepicker": false, + "fiscalYearStartMonth": 0 + }, + "title": "Panel ds inheritance ", + "variables": [] + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.panel-target-missing-refid.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.panel-target-missing-refid.v2beta1.json new file mode 100644 index 00000000000..c407d612154 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.panel-target-missing-refid.v2beta1.json @@ -0,0 +1,231 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v2beta1", + "metadata": { + "name": "ad5vfcn", + "namespace": "default", + "uid": "dlMZZl6GndU8gJLUQSmgZxXBPCNXyXhNBeQJhHXl0r4X", + "resourceVersion": "2", + "generation": 2, + "creationTimestamp": "2025-11-28T10:14:21Z", + "labels": { + "grafana.app/deprecatedInternalID": "288" + }, + "annotations": { + "grafana.app/createdBy": "user:eex2ofwuj0agwd", + "grafana.app/updatedBy": "user:eex2ofwuj0agwd", + "grafana.app/updatedTimestamp": "2025-11-28T10:15:06Z" + } + }, + "spec": { + "annotations": [ + { + "kind": "AnnotationQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "grafana", + "version": "v0", + "datasource": { + "name": "-- Grafana --" + }, + "spec": {} + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "builtIn": true, + "legacyOptions": { + "type": "dashboard" + } + } + } + ], + "cursorSync": "Off", + "editable": true, + "elements": { + "panel-1": { + "kind": "Panel", + "spec": { + "id": 1, + "title": "New panel", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "testdata", + "version": "v0", + "datasource": { + "name": "gdev-testdata" + }, + "spec": { + "queryType": "randomWalk" + } + }, + "refId": "A", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "testdata", + "version": "v0", + "datasource": { + "name": "gdev-testdata" + }, + "spec": { + "queryType": "randomWalk" + } + }, + "refId": "B", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "timeseries", + "version": "12.4.0-pre", + "spec": { + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + } + } + } + } + } + }, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 0, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-1" + } + } + } + ] + } + }, + "links": [], + "liveNow": false, + "preload": false, + "tags": [], + "timeSettings": { + "timezone": "browser", + "from": "now-6h", + "to": "now", + "autoRefresh": "", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "hideTimepicker": false, + "fiscalYearStartMonth": 0 + }, + "title": "Panel ds inheritance ", + "variables": [] + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go index 81c52bcc43e..db86f8c87a9 100644 --- a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go +++ b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go @@ -2006,6 +2006,28 @@ func transformPanelQueries(ctx context.Context, panelMap map[string]interface{}, } } + // Ensure each target has a non-empty refId. We only fill missing refIds; + existingRefIds := make(map[string]bool) + for _, target := range targets { + if targetMap, ok := target.(map[string]interface{}); ok { + if refId := schemaversion.GetStringValue(targetMap, "refId"); refId != "" { + existingRefIds[refId] = true + } + } + } + for _, target := range targets { + targetMap, ok := target.(map[string]interface{}) + if !ok { + continue + } + refId := schemaversion.GetStringValue(targetMap, "refId") + if refId == "" { + refId = nextAvailableRefId(existingRefIds) + targetMap["refId"] = refId + existingRefIds[refId] = true + } + } + queries := make([]dashv2alpha1.DashboardPanelQueryKind, 0, len(targets)) for _, target := range targets { @@ -2018,6 +2040,27 @@ func transformPanelQueries(ctx context.Context, panelMap map[string]interface{}, return queries } +// nextAvailableRefId returns the next unused refId using the same sequence as the +// frontend helper (A, B, ..., Z, AA, AB, ...). +func nextAvailableRefId(existing map[string]bool) string { + const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + var refIdFromIndex func(num int) string + refIdFromIndex = func(num int) string { + if num < len(letters) { + return string(letters[num]) + } + return refIdFromIndex(num/len(letters)-1) + string(letters[num%len(letters)]) + } + + for i := 0; ; i++ { + refId := refIdFromIndex(i) + if !existing[refId] { + return refId + } + } +} + func transformSingleQuery(ctx context.Context, targetMap map[string]interface{}, panelDatasource *dashv2alpha1.DashboardDataSourceRef, dsIndexProvider schemaversion.DataSourceIndexProvider) dashv2alpha1.DashboardPanelQueryKind { refId := schemaversion.GetStringValue(targetMap, "refId", "A") if refId == "" { diff --git a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1_test.go b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1_test.go index a80bf9c8072..6c0e8f4f919 100644 --- a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1_test.go +++ b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1_test.go @@ -210,6 +210,48 @@ func TestV1beta1ToV2alpha1(t *testing.T) { assert.Equal(t, "", query.Spec.Query.Kind, "Query kind should be empty when datasource is empty object {}") }, }, + { + name: "missing refIds are assigned while existing refIds are preserved", + createV1beta1: func() *dashv1.Dashboard { + return &dashv1.Dashboard{ + Spec: dashv1.DashboardSpec{ + Object: map[string]interface{}{ + "title": "Test Dashboard", + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "type": "bargauge", + "targets": []interface{}{ + map[string]interface{}{ + "refId": "", + "scenarioId": "random_walk", + }, + map[string]interface{}{ + "refId": "A", + "scenarioId": "random_walk", + }, + map[string]interface{}{ + "refId": "", + "scenarioId": "random_walk", + }, + }, + }, + }, + }, + }, + } + }, + validateV2alpha1: func(t *testing.T, v2alpha1 *dashv2alpha1.Dashboard) { + require.NotNil(t, v2alpha1.Spec.Elements["panel-1"]) + panel := v2alpha1.Spec.Elements["panel-1"].PanelKind + require.NotNil(t, panel) + + require.Len(t, panel.Spec.Data.Spec.Queries, 3) + assert.Equal(t, "B", panel.Spec.Data.Spec.Queries[0].Spec.RefId) + assert.Equal(t, "A", panel.Spec.Data.Spec.Queries[1].Spec.RefId) + assert.Equal(t, "C", panel.Spec.Data.Spec.Queries[2].Spec.RefId) + }, + }, } for _, tt := range testCases { From 3b254467e1cce8551eb218e324489ff87b341615 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20=C5=A0tibran=C3=BD?= Date: Thu, 18 Dec 2025 10:18:44 +0100 Subject: [PATCH 007/229] DB snapshot for MySQL. (#115402) * DB snapshot for MySQL. * Fix double import. * Update schema from version 12.4.0-20306503000 --- .../docker/blocks/mysql/docker-compose.yaml | 10 +- pkg/services/sqlstore/migrator/dialect.go | 5 +- pkg/services/sqlstore/migrator/migrator.go | 2 +- .../sqlstore/migrator/mysql_dialect.go | 88 + .../sqlstore/migrator/snapshot/00-schema.sql | 2325 +++++++++++++++++ .../snapshot/96-secret_migration_log.sql | 57 + .../snapshot/97-resource_migration_log.sql | 60 + .../sqlstore/migrator/snapshot/98-data.sql | 469 ++++ .../migrator/snapshot/99-migration_log.sql | 850 ++++++ 9 files changed, 3862 insertions(+), 4 deletions(-) create mode 100644 pkg/services/sqlstore/migrator/snapshot/00-schema.sql create mode 100644 pkg/services/sqlstore/migrator/snapshot/96-secret_migration_log.sql create mode 100644 pkg/services/sqlstore/migrator/snapshot/97-resource_migration_log.sql create mode 100644 pkg/services/sqlstore/migrator/snapshot/98-data.sql create mode 100644 pkg/services/sqlstore/migrator/snapshot/99-migration_log.sql diff --git a/devenv/docker/blocks/mysql/docker-compose.yaml b/devenv/docker/blocks/mysql/docker-compose.yaml index cd5de0e6e67..c0c5403ef25 100644 --- a/devenv/docker/blocks/mysql/docker-compose.yaml +++ b/devenv/docker/blocks/mysql/docker-compose.yaml @@ -7,7 +7,15 @@ MYSQL_PASSWORD: password ports: - "3306:3306" - command: [mysqld, --character-set-server=utf8mb4, --collation-server=utf8mb4_unicode_ci, --innodb_monitor_enable=all, --default-authentication-plugin=mysql_native_password] + command: + - mysqld + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + - --innodb_monitor_enable=all + - --default-authentication-plugin=mysql_native_password + # Please keep sql-require-primary-key option enabled, to make sure we don't accidentally introduce migration + # adding new table without PK. + - --sql-require-primary-key=ON fake-mysql-data: image: grafana/fake-data-gen diff --git a/pkg/services/sqlstore/migrator/dialect.go b/pkg/services/sqlstore/migrator/dialect.go index 623bada2253..d75f87989f7 100644 --- a/pkg/services/sqlstore/migrator/dialect.go +++ b/pkg/services/sqlstore/migrator/dialect.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/sqlstore/session" "github.com/grafana/grafana/pkg/util/xorm" ) @@ -77,7 +78,7 @@ type Dialect interface { // CreateDatabaseFromSnapshot is called when migration log table is not found. // Dialect can recreate all tables from existing snapshot. After successful (nil error) return, // migrator will list migrations from the log, and apply all missing migrations. - CreateDatabaseFromSnapshot(ctx context.Context, engine *xorm.Engine, migrationLogTableName string) error + CreateDatabaseFromSnapshot(ctx context.Context, engine *xorm.Engine, migrationLogTableName string, logger log.Logger) error IsUniqueConstraintViolation(err error) bool ErrorMessage(err error) string @@ -355,7 +356,7 @@ func (b *BaseDialect) CleanDB(engine *xorm.Engine) error { return nil } -func (b *BaseDialect) CreateDatabaseFromSnapshot(ctx context.Context, engine *xorm.Engine, tableName string) error { +func (b *BaseDialect) CreateDatabaseFromSnapshot(ctx context.Context, engine *xorm.Engine, migrationLogTableName string, logger log.Logger) error { return nil } diff --git a/pkg/services/sqlstore/migrator/migrator.go b/pkg/services/sqlstore/migrator/migrator.go index 88a2c65d057..ca921affdac 100644 --- a/pkg/services/sqlstore/migrator/migrator.go +++ b/pkg/services/sqlstore/migrator/migrator.go @@ -250,7 +250,7 @@ func (mg *Migrator) run(ctx context.Context) (err error) { if !migrationLogExists { // Check if dialect can initialize database from a snapshot. - err := mg.Dialect.CreateDatabaseFromSnapshot(ctx, mg.DBEngine, mg.tableName) + err := mg.Dialect.CreateDatabaseFromSnapshot(ctx, mg.DBEngine, mg.tableName, logger) if err != nil { return fmt.Errorf("failed to create database from snapshot: %w", err) } diff --git a/pkg/services/sqlstore/migrator/mysql_dialect.go b/pkg/services/sqlstore/migrator/mysql_dialect.go index 5a76b4555ca..c356caa8f1c 100644 --- a/pkg/services/sqlstore/migrator/mysql_dialect.go +++ b/pkg/services/sqlstore/migrator/mysql_dialect.go @@ -1,15 +1,19 @@ package migrator import ( + "context" "database/sql" + "embed" "errors" "fmt" + "path" "strconv" "strings" "github.com/VividCortex/mysqlerr" "github.com/go-sql-driver/mysql" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/util/xorm" ) @@ -317,3 +321,87 @@ func (db *MySQLDialect) GetDBName(dsn string) (string, error) { return cfg.DBName, nil } + +//go:embed snapshot/*.sql +var sqlFiles embed.FS + +func (db *MySQLDialect) CreateDatabaseFromSnapshot(ctx context.Context, engine *xorm.Engine, migrationLogTableName string, logger log.Logger) error { + // Entire schema is generated only when called with "migration_log" table. + // Normally if schema creates other "*_migration_log" tables, we don't get here. However if new migration table is introduced by later migration, + // CreateDatabaseFromSnapshot may be called for given table, but if it wasn't part of original schema, we can't do anything here. + if migrationLogTableName != "migration_log" { + return nil + } + + entries, err := sqlFiles.ReadDir("snapshot") + if err != nil { + return err + } + + logger.Info("creating database schema from snapshot") + + for _, entry := range entries { + logger.Debug("using snapshot file", "file", entry.Name()) + data, err := sqlFiles.ReadFile(path.Join("snapshot", entry.Name())) + if err != nil { + return err + } + + statements := extractStatements(string(data)) + if err := db.executeStatements(engine, statements); err != nil { + return err + } + } + + logger.Info("successfully created database schema from snapshot") + return nil +} + +func extractStatements(schema string) []string { + lines := strings.Split(schema, "\n") + + statements := make([]string, 0, len(lines)) + + sb := strings.Builder{} + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "--") { + continue + } + + if line == "" { + continue + } + + endOfStatement := false + if strings.HasSuffix(line, ";") { + endOfStatement = true + line = strings.TrimSuffix(line, ";") + } + + if sb.Len() > 0 { + sb.WriteRune('\n') + } + sb.WriteString(line) + if endOfStatement && sb.Len() > 0 { + statements = append(statements, sb.String()) + sb.Reset() + } + } + + if sb.Len() > 0 { + statements = append(statements, sb.String()) + } + return statements +} + +func (s *MySQLDialect) executeStatements(engine *xorm.Engine, statements []string) error { + sess := engine.NewSession() + for _, s := range statements { + _, err := sess.Exec(s) + if err != nil { + return fmt.Errorf("statement %s failed with error: %v", s, err) + } + } + return nil +} diff --git a/pkg/services/sqlstore/migrator/snapshot/00-schema.sql b/pkg/services/sqlstore/migrator/snapshot/00-schema.sql new file mode 100644 index 00000000000..9907f7083cf --- /dev/null +++ b/pkg/services/sqlstore/migrator/snapshot/00-schema.sql @@ -0,0 +1,2325 @@ +-- MySQL dump 10.13 Distrib 8.4.5, for Linux (x86_64) +-- +-- Host: localhost Database: hg_dump +-- ------------------------------------------------------ +-- Server version 8.4.5 + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!50503 SET NAMES utf8mb4 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- +-- Table structure for table `alert` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `alert` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `version` bigint NOT NULL, + `dashboard_id` bigint NOT NULL, + `panel_id` bigint NOT NULL, + `org_id` bigint NOT NULL, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `message` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `state` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `settings` mediumtext COLLATE utf8mb4_unicode_ci, + `frequency` bigint NOT NULL, + `handler` bigint NOT NULL, + `severity` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `silenced` tinyint(1) NOT NULL, + `execution_error` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `eval_data` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `eval_date` datetime DEFAULT NULL, + `new_state_date` datetime NOT NULL, + `state_changes` int NOT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + `for` bigint DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `IDX_alert_org_id_id` (`org_id`,`id`), + KEY `IDX_alert_state` (`state`), + KEY `IDX_alert_dashboard_id` (`dashboard_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `alert_configuration` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `alert_configuration` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `alertmanager_configuration` mediumtext COLLATE utf8mb4_unicode_ci, + `configuration_version` varchar(3) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` int NOT NULL, + `default` tinyint(1) NOT NULL DEFAULT '0', + `org_id` bigint NOT NULL DEFAULT '0', + `configuration_hash` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'not-yet-calculated', + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_alert_configuration_org_id` (`org_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `alert_configuration_history` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `alert_configuration_history` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `org_id` bigint NOT NULL DEFAULT '0', + `alertmanager_configuration` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `configuration_hash` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'not-yet-calculated', + `configuration_version` varchar(3) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` int NOT NULL, + `default` tinyint(1) NOT NULL DEFAULT '0', + `last_applied` int NOT NULL DEFAULT '0', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `alert_image` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `alert_image` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `token` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `path` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `url` varchar(2048) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` datetime NOT NULL, + `expires_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_alert_image_token` (`token`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `alert_instance` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `alert_instance` ( + `rule_org_id` bigint NOT NULL, + `rule_uid` varchar(40) COLLATE utf8mb4_unicode_ci NOT NULL, + `labels` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `labels_hash` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `current_state` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `current_state_since` bigint NOT NULL, + `last_eval_time` bigint NOT NULL, + `current_state_end` bigint NOT NULL DEFAULT '0', + `current_reason` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `result_fingerprint` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `resolved_at` bigint DEFAULT NULL, + `last_sent_at` bigint DEFAULT NULL, + `fired_at` bigint DEFAULT NULL, + `annotations` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + PRIMARY KEY (`rule_org_id`,`rule_uid`,`labels_hash`), + KEY `IDX_alert_instance_rule_org_id_rule_uid_current_state` (`rule_org_id`,`rule_uid`,`current_state`), + KEY `IDX_alert_instance_rule_org_id_current_state` (`rule_org_id`,`current_state`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `alert_notification` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `alert_notification` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `org_id` bigint NOT NULL, + `name` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `settings` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + `is_default` tinyint(1) NOT NULL DEFAULT '0', + `frequency` bigint DEFAULT NULL, + `send_reminder` tinyint(1) DEFAULT '0', + `disable_resolve_message` tinyint(1) NOT NULL DEFAULT '0', + `uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `secure_settings` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_alert_notification_org_id_uid` (`org_id`,`uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `alert_notification_state` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `alert_notification_state` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `org_id` bigint NOT NULL, + `alert_id` bigint NOT NULL, + `notifier_id` bigint NOT NULL, + `state` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `version` bigint NOT NULL, + `updated_at` bigint NOT NULL, + `alert_rule_state_updated_version` bigint NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_alert_notification_state_org_id_alert_id_notifier_id` (`org_id`,`alert_id`,`notifier_id`), + KEY `IDX_alert_notification_state_alert_id` (`alert_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `alert_rule` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `alert_rule` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `org_id` bigint NOT NULL, + `title` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `condition` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `data` mediumtext COLLATE utf8mb4_unicode_ci, + `updated` datetime NOT NULL, + `interval_seconds` bigint NOT NULL DEFAULT '60', + `version` int NOT NULL DEFAULT '0', + `uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '0', + `namespace_uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + `rule_group` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + `no_data_state` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'NoData', + `exec_err_state` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'Alerting', + `for` bigint NOT NULL DEFAULT '0', + `annotations` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `labels` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `dashboard_uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `panel_id` bigint DEFAULT NULL, + `rule_group_idx` bigint DEFAULT NULL, + `is_paused` tinyint(1) NOT NULL DEFAULT '0', + `notification_settings` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `record` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `metadata` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `updated_by` varchar(190) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `guid` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `keep_firing_for` bigint NOT NULL DEFAULT '0', + `missing_series_evals_to_resolve` smallint DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_alert_rule_org_id_uid` (`org_id`,`uid`), + UNIQUE KEY `UQE_alert_rule_guid` (`guid`), + KEY `IDX_alert_rule_org_id_namespace_uid_rule_group` (`org_id`,`namespace_uid`,`rule_group`), + KEY `IDX_alert_rule_org_id_dashboard_uid_panel_id` (`org_id`,`dashboard_uid`,`panel_id`), + KEY `IDX_alert_rule_org_id_namespace_uid_rule_group_rule_group_idx` (`org_id`,`namespace_uid`,`rule_group`,`rule_group_idx`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `alert_rule_state` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `alert_rule_state` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `org_id` bigint NOT NULL, + `rule_uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `data` longblob NOT NULL, + `updated_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_alert_rule_state_org_id_rule_uid` (`org_id`,`rule_uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `alert_rule_tag` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `alert_rule_tag` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `alert_id` bigint NOT NULL, + `tag_id` bigint NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_alert_rule_tag_alert_id_tag_id` (`alert_id`,`tag_id`), + KEY `IDX_alert_rule_tag_alert_id` (`alert_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `alert_rule_version` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `alert_rule_version` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `rule_org_id` bigint NOT NULL, + `rule_uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '0', + `rule_namespace_uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `rule_group` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `parent_version` int NOT NULL, + `restored_from` int NOT NULL, + `version` int NOT NULL, + `created` datetime NOT NULL, + `title` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `condition` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `data` mediumtext COLLATE utf8mb4_unicode_ci, + `interval_seconds` bigint NOT NULL, + `no_data_state` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'NoData', + `exec_err_state` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'Alerting', + `for` bigint NOT NULL DEFAULT '0', + `annotations` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `labels` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `rule_group_idx` bigint DEFAULT NULL, + `is_paused` tinyint(1) NOT NULL DEFAULT '0', + `message` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `notification_settings` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `record` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `metadata` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `created_by` varchar(190) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `rule_guid` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `keep_firing_for` bigint NOT NULL DEFAULT '0', + `missing_series_evals_to_resolve` smallint DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_alert_rule_version_rule_org_id_rule_uid_rule_guid_version` (`rule_org_id`,`rule_uid`,`rule_guid`,`version`), + UNIQUE KEY `UQE_alert_rule_version_rule_guid_version` (`rule_guid`,`version`), + KEY `IDX_alert_rule_version_rule_org_id_rule_namespace_uid_rule_group` (`rule_org_id`,`rule_namespace_uid`,`rule_group`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `annotation` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `annotation` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `org_id` bigint NOT NULL, + `alert_id` bigint DEFAULT NULL, + `user_id` bigint DEFAULT NULL, + `dashboard_id` bigint DEFAULT NULL, + `panel_id` bigint DEFAULT NULL, + `category_id` bigint DEFAULT NULL, + `type` varchar(25) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `title` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `text` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `metric` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `prev_state` varchar(40) COLLATE utf8mb4_unicode_ci NOT NULL, + `new_state` varchar(40) COLLATE utf8mb4_unicode_ci NOT NULL, + `data` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `epoch` bigint NOT NULL, + `region_id` bigint DEFAULT '0', + `tags` varchar(4096) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created` bigint DEFAULT '0', + `updated` bigint DEFAULT '0', + `epoch_end` bigint NOT NULL DEFAULT '0', + `dashboard_uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `IDX_annotation_org_id_alert_id` (`org_id`,`alert_id`), + KEY `IDX_annotation_org_id_type` (`org_id`,`type`), + KEY `IDX_annotation_org_id_created` (`org_id`,`created`), + KEY `IDX_annotation_org_id_updated` (`org_id`,`updated`), + KEY `IDX_annotation_org_id_dashboard_id_epoch_end_epoch` (`org_id`,`dashboard_id`,`epoch_end`,`epoch`), + KEY `IDX_annotation_org_id_epoch_end_epoch` (`org_id`,`epoch_end`,`epoch`), + KEY `IDX_annotation_alert_id` (`alert_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `annotation_tag` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `annotation_tag` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `annotation_id` bigint NOT NULL, + `tag_id` bigint NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_annotation_tag_annotation_id_tag_id` (`annotation_id`,`tag_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `anon_device` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `anon_device` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `client_ip` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` datetime NOT NULL, + `device_id` varchar(127) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `updated_at` datetime NOT NULL, + `user_agent` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_anon_device_device_id` (`device_id`), + KEY `IDX_anon_device_updated_at` (`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `api_key` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `api_key` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `org_id` bigint NOT NULL, + `name` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `key` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `role` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + `expires` bigint DEFAULT NULL, + `service_account_id` bigint DEFAULT NULL, + `last_used_at` datetime DEFAULT NULL, + `is_revoked` tinyint(1) DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_api_key_key` (`key`), + UNIQUE KEY `UQE_api_key_org_id_name` (`org_id`,`name`), + KEY `IDX_api_key_org_id` (`org_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `builtin_role` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `builtin_role` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `role` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `role_id` bigint NOT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + `org_id` bigint NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_builtin_role_org_id_role_id_role` (`org_id`,`role_id`,`role`), + KEY `IDX_builtin_role_role_id` (`role_id`), + KEY `IDX_builtin_role_role` (`role`), + KEY `IDX_builtin_role_org_id` (`org_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `cache_data` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `cache_data` ( + `cache_key` varchar(168) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `data` blob NOT NULL, + `expires` int NOT NULL, + `created_at` int NOT NULL, + PRIMARY KEY (`cache_key`), + UNIQUE KEY `UQE_cache_data_cache_key` (`cache_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `cloud_migration_resource` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `cloud_migration_resource` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `resource_type` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `resource_uid` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL, + `status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `error_string` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `snapshot_uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `name` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `parent_name` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `error_code` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `cloud_migration_session` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `cloud_migration_session` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `auth_token` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `slug` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `stack_id` bigint NOT NULL, + `region_slug` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `cluster_slug` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + `org_id` bigint NOT NULL DEFAULT '1', + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_cloud_migration_session_uid` (`uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `cloud_migration_snapshot` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `cloud_migration_snapshot` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `session_uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + `finished` datetime DEFAULT NULL, + `upload_url` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `status` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `local_directory` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `gms_snapshot_uid` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `encryption_key` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `error_string` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `resource_storage_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `encryption_algo` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `metadata` blob, + `public_key` blob, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_cloud_migration_snapshot_uid` (`uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `cloud_migration_snapshot_partition` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `cloud_migration_snapshot_partition` ( + `snapshot_uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `partition_number` int NOT NULL, + `resource_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `data` longblob NOT NULL, + PRIMARY KEY (`snapshot_uid`,`resource_type`,`partition_number`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `correlation` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `correlation` ( + `uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `org_id` bigint NOT NULL DEFAULT '0', + `source_uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `target_uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `label` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `config` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `provisioned` tinyint(1) NOT NULL DEFAULT '0', + `type` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'query', + PRIMARY KEY (`uid`,`org_id`,`source_uid`), + KEY `IDX_correlation_uid` (`uid`), + KEY `IDX_correlation_source_uid` (`source_uid`), + KEY `IDX_correlation_org_id` (`org_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `dashboard` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `dashboard` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `version` int NOT NULL, + `slug` varchar(189) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `title` varchar(189) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `data` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `org_id` bigint NOT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + `updated_by` int DEFAULT NULL, + `created_by` int DEFAULT NULL, + `gnet_id` bigint DEFAULT NULL, + `plugin_id` varchar(189) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `folder_id` bigint NOT NULL DEFAULT '0', + `is_folder` tinyint(1) NOT NULL DEFAULT '0', + `has_acl` tinyint(1) NOT NULL DEFAULT '0', + `uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `is_public` tinyint(1) NOT NULL DEFAULT '0', + `deleted` datetime DEFAULT NULL, + `api_version` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `folder_uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_dashboard_org_id_uid` (`org_id`,`uid`), + KEY `IDX_dashboard_org_id` (`org_id`), + KEY `IDX_dashboard_gnet_id` (`gnet_id`), + KEY `IDX_dashboard_org_id_plugin_id` (`org_id`,`plugin_id`), + KEY `IDX_dashboard_title` (`title`), + KEY `IDX_dashboard_is_folder` (`is_folder`), + KEY `IDX_dashboard_deleted` (`deleted`), + KEY `IDX_dashboard_org_id_folder_id_title` (`org_id`,`folder_id`,`title`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `dashboard_acl` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `dashboard_acl` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `org_id` bigint NOT NULL, + `dashboard_id` bigint NOT NULL, + `user_id` bigint DEFAULT NULL, + `team_id` bigint DEFAULT NULL, + `permission` smallint NOT NULL DEFAULT '4', + `role` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_dashboard_acl_dashboard_id_user_id` (`dashboard_id`,`user_id`), + UNIQUE KEY `UQE_dashboard_acl_dashboard_id_team_id` (`dashboard_id`,`team_id`), + KEY `IDX_dashboard_acl_dashboard_id` (`dashboard_id`), + KEY `IDX_dashboard_acl_user_id` (`user_id`), + KEY `IDX_dashboard_acl_team_id` (`team_id`), + KEY `IDX_dashboard_acl_org_id_role` (`org_id`,`role`), + KEY `IDX_dashboard_acl_permission` (`permission`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `dashboard_provisioning` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `dashboard_provisioning` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `dashboard_id` bigint DEFAULT NULL, + `name` varchar(150) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `external_id` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `updated` int NOT NULL DEFAULT '0', + `check_sum` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `IDX_dashboard_provisioning_dashboard_id` (`dashboard_id`), + KEY `IDX_dashboard_provisioning_dashboard_id_name` (`dashboard_id`,`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `dashboard_public` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `dashboard_public` ( + `uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `dashboard_uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `org_id` bigint NOT NULL, + `time_settings` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `template_variables` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `access_token` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `created_by` int NOT NULL, + `updated_by` int DEFAULT NULL, + `created_at` datetime NOT NULL, + `updated_at` datetime DEFAULT NULL, + `is_enabled` tinyint(1) NOT NULL DEFAULT '0', + `annotations_enabled` tinyint(1) NOT NULL DEFAULT '0', + `time_selection_enabled` tinyint(1) NOT NULL DEFAULT '0', + `share` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'public', + PRIMARY KEY (`uid`), + UNIQUE KEY `UQE_dashboard_public_config_uid` (`uid`), + UNIQUE KEY `UQE_dashboard_public_config_access_token` (`access_token`), + KEY `IDX_dashboard_public_config_org_id_dashboard_uid` (`org_id`,`dashboard_uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `dashboard_public_email_share` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `dashboard_public_email_share` ( + `uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `public_dashboard_uid` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `recipient` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `type` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'email', + `created_at` datetime NOT NULL, + `updated_at` datetime NOT NULL, + PRIMARY KEY (`uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `dashboard_public_magic_link` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `dashboard_public_magic_link` ( + `uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `token_uuid` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `public_dashboard_uid` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` datetime NOT NULL, + `updated_at` datetime NOT NULL, + PRIMARY KEY (`uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `dashboard_public_session` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `dashboard_public_session` ( + `uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `cookie_uuid` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `public_dashboard_uid` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` datetime NOT NULL, + `updated_at` datetime NOT NULL, + `last_seen_at` datetime DEFAULT NULL, + PRIMARY KEY (`uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `dashboard_public_usage_by_day` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `dashboard_public_usage_by_day` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `public_dashboard_uid` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `day` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `views` bigint NOT NULL, + `queries` bigint NOT NULL, + `errors` bigint NOT NULL, + `load_duration` float NOT NULL, + `cached_queries` bigint NOT NULL DEFAULT '0', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `dashboard_snapshot` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `dashboard_snapshot` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `key` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `delete_key` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `org_id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `external` tinyint(1) NOT NULL, + `external_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `dashboard` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `expires` datetime NOT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + `external_delete_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `dashboard_encrypted` mediumblob, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_dashboard_snapshot_key` (`key`), + UNIQUE KEY `UQE_dashboard_snapshot_delete_key` (`delete_key`), + KEY `IDX_dashboard_snapshot_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `dashboard_tag` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `dashboard_tag` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `dashboard_id` bigint NOT NULL, + `term` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `dashboard_uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `org_id` bigint DEFAULT '1', + PRIMARY KEY (`id`), + KEY `IDX_dashboard_tag_dashboard_id` (`dashboard_id`), + KEY `IDX_dashboard_tag_dashboard_uid` (`dashboard_uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `dashboard_usage_by_day` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `dashboard_usage_by_day` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `dashboard_id` bigint NOT NULL, + `day` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `views` bigint NOT NULL, + `queries` bigint NOT NULL, + `errors` bigint NOT NULL, + `load_duration` float NOT NULL, + `cached_queries` bigint NOT NULL DEFAULT '0', + `dashboard_uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `org_id` bigint DEFAULT '1', + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_dashboard_usage_by_day_dashboard_id_day` (`dashboard_id`,`day`), + UNIQUE KEY `UQE_dashboard_usage_by_day_dashboard_uid_org_id_day` (`dashboard_uid`,`org_id`,`day`), + KEY `IDX_dashboard_usage_by_day_dashboard_id` (`dashboard_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `dashboard_usage_sums` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `dashboard_usage_sums` ( + `dashboard_id` bigint NOT NULL, + `updated` datetime NOT NULL, + `views_last_1_days` bigint NOT NULL, + `views_last_7_days` bigint NOT NULL, + `views_last_30_days` bigint NOT NULL, + `views_total` bigint NOT NULL, + `queries_last_1_days` bigint NOT NULL, + `queries_last_7_days` bigint NOT NULL, + `queries_last_30_days` bigint NOT NULL, + `queries_total` bigint NOT NULL, + `errors_last_1_days` bigint NOT NULL DEFAULT '0', + `errors_last_7_days` bigint NOT NULL DEFAULT '0', + `errors_last_30_days` bigint NOT NULL DEFAULT '0', + `errors_total` bigint NOT NULL DEFAULT '0', + `dashboard_uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `org_id` bigint DEFAULT '1', + PRIMARY KEY (`dashboard_id`), + UNIQUE KEY `UQE_dashboard_usage_sums_org_id_dashboard_uid` (`org_id`,`dashboard_uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `dashboard_version` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `dashboard_version` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `dashboard_id` bigint NOT NULL, + `parent_version` int NOT NULL, + `restored_from` int NOT NULL, + `version` int NOT NULL, + `created` datetime NOT NULL, + `created_by` bigint NOT NULL, + `message` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `data` mediumtext COLLATE utf8mb4_unicode_ci, + `api_version` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_dashboard_version_dashboard_id_version` (`dashboard_id`,`version`), + KEY `IDX_dashboard_version_dashboard_id` (`dashboard_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `data_keys` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `data_keys` ( + `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `active` tinyint(1) NOT NULL, + `scope` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `provider` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `encrypted_data` blob NOT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + `label` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `data_source` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `data_source` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `org_id` bigint NOT NULL, + `version` int NOT NULL, + `type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `name` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `access` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `user` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `database` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `basic_auth` tinyint(1) NOT NULL, + `basic_auth_user` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `basic_auth_password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `is_default` tinyint(1) NOT NULL, + `json_data` mediumtext COLLATE utf8mb4_unicode_ci, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + `with_credentials` tinyint(1) NOT NULL DEFAULT '0', + `secure_json_data` mediumtext COLLATE utf8mb4_unicode_ci, + `read_only` tinyint(1) DEFAULT NULL, + `uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '0', + `is_prunable` tinyint(1) DEFAULT '0', + `api_version` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_data_source_org_id_name` (`org_id`,`name`), + UNIQUE KEY `UQE_data_source_org_id_uid` (`org_id`,`uid`), + KEY `IDX_data_source_org_id` (`org_id`), + KEY `IDX_data_source_org_id_is_default` (`org_id`,`is_default`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `data_source_acl` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `data_source_acl` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `org_id` bigint NOT NULL, + `data_source_id` bigint NOT NULL, + `team_id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `permission` smallint NOT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_data_source_acl_data_source_id_team_id_user_id` (`data_source_id`,`team_id`,`user_id`), + KEY `IDX_data_source_acl_data_source_id` (`data_source_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `data_source_cache` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `data_source_cache` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `data_source_id` bigint NOT NULL, + `enabled` tinyint(1) NOT NULL, + `ttl_ms` bigint NOT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + `use_default_ttl` tinyint(1) NOT NULL DEFAULT '1', + `data_source_uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '0', + `ttl_resources_ms` bigint NOT NULL DEFAULT '300000', + PRIMARY KEY (`id`), + KEY `IDX_data_source_cache_data_source_id` (`data_source_id`), + KEY `IDX_data_source_cache_data_source_uid` (`data_source_uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `data_source_usage_by_day` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `data_source_usage_by_day` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `data_source_id` bigint NOT NULL, + `day` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `queries` bigint NOT NULL, + `errors` bigint NOT NULL, + `load_duration_ms` bigint NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_data_source_usage_by_day_data_source_id_day` (`data_source_id`,`day`), + KEY `IDX_data_source_usage_by_day_data_source_id` (`data_source_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `entity_event` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `entity_event` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `entity_id` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `event_type` varchar(8) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `created` bigint NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `file` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `file` ( + `path` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `path_hash` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `parent_folder_path_hash` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `contents` mediumblob, + `etag` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `cache_control` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `content_disposition` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `updated` datetime NOT NULL, + `created` datetime NOT NULL, + `size` bigint NOT NULL, + `mime_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + PRIMARY KEY (`path_hash`), + KEY `IDX_file_parent_folder_path_hash` (`parent_folder_path_hash`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `file_meta` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `file_meta` ( + `path_hash` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `key` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `value` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + PRIMARY KEY (`path_hash`,`key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `folder` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `folder` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `org_id` bigint NOT NULL, + `title` varchar(189) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `parent_uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_folder_org_id_uid` (`org_id`,`uid`), + KEY `IDX_folder_org_id_parent_uid` (`org_id`,`parent_uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `kv_store` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `kv_store` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `org_id` bigint NOT NULL, + `namespace` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `key` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `value` longtext COLLATE utf8mb4_unicode_ci NOT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_kv_store_org_id_namespace_key` (`org_id`,`namespace`,`key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `library_element` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `library_element` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `org_id` bigint NOT NULL, + `folder_id` bigint NOT NULL, + `uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `name` varchar(150) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `kind` bigint NOT NULL, + `type` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `description` varchar(2048) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `model` mediumtext COLLATE utf8mb4_unicode_ci NOT NULL, + `created` datetime NOT NULL, + `created_by` bigint NOT NULL, + `updated` datetime NOT NULL, + `updated_by` bigint NOT NULL, + `version` bigint NOT NULL, + `folder_uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_library_element_org_id_uid` (`org_id`,`uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `library_element_connection` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `library_element_connection` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `element_id` bigint NOT NULL, + `kind` bigint NOT NULL, + `connection_id` bigint NOT NULL, + `created` datetime NOT NULL, + `created_by` bigint NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_library_element_connection_element_id_kind_connection_id` (`element_id`,`kind`,`connection_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `license_token` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `license_token` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `token` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `login_attempt` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `login_attempt` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `username` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `ip_address` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created` bigint DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `IDX_login_attempt_username` (`username`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `migration_log` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `migration_log` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `migration_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `sql` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `success` tinyint(1) NOT NULL, + `error` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `timestamp` datetime NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `ngalert_configuration` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ngalert_configuration` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `org_id` bigint NOT NULL, + `alertmanagers` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `created_at` int NOT NULL, + `updated_at` int NOT NULL, + `send_alerts_to` smallint NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_ngalert_configuration_org_id` (`org_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `org` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `org` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `version` int NOT NULL, + `name` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `address1` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `address2` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `city` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `state` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `zip_code` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `country` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `billing_email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_org_name` (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `org_user` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `org_user` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `org_id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `role` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_org_user_org_id_user_id` (`org_id`,`user_id`), + KEY `IDX_org_user_org_id` (`org_id`), + KEY `IDX_org_user_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `permission` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `permission` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `role_id` bigint NOT NULL, + `action` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `scope` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + `kind` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `attribute` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `identifier` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_permission_action_scope_role_id` (`action`,`scope`,`role_id`), + KEY `IDX_permission_identifier` (`identifier`), + KEY `IDX_permission_role_id_action` (`role_id`,`action`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `playlist` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `playlist` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `interval` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `org_id` bigint NOT NULL, + `created_at` bigint NOT NULL DEFAULT '0', + `updated_at` bigint NOT NULL DEFAULT '0', + `uid` varchar(80) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_playlist_org_id_uid` (`org_id`,`uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `playlist_item` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `playlist_item` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `playlist_id` bigint NOT NULL, + `type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `value` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `title` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `order` int NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `plugin_setting` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `plugin_setting` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `org_id` bigint NOT NULL DEFAULT '1', + `plugin_id` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `enabled` tinyint(1) NOT NULL, + `pinned` tinyint(1) NOT NULL, + `json_data` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `secure_json_data` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + `plugin_version` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_plugin_setting_org_id_plugin_id` (`org_id`,`plugin_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `preferences` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `preferences` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `org_id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `version` int NOT NULL, + `home_dashboard_id` bigint NOT NULL, + `timezone` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `theme` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + `team_id` bigint DEFAULT NULL, + `week_start` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `json_data` mediumtext COLLATE utf8mb4_unicode_ci, + `home_dashboard_uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `IDX_preferences_org_id` (`org_id`), + KEY `IDX_preferences_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `provenance_type` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `provenance_type` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `org_id` bigint NOT NULL, + `record_key` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `record_type` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `provenance` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_provenance_type_record_type_record_key_org_id` (`record_type`,`record_key`,`org_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `query_history` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `query_history` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `org_id` bigint NOT NULL, + `datasource_uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `created_by` bigint DEFAULT NULL, + `created_at` int NOT NULL, + `comment` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `queries` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + PRIMARY KEY (`id`), + KEY `IDX_query_history_org_id_created_by_datasource_uid` (`org_id`,`created_by`,`datasource_uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `query_history_details` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `query_history_details` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `query_history_item_uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `datasource_uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `query_history_star` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `query_history_star` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `query_uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `user_id` bigint DEFAULT NULL, + `org_id` bigint NOT NULL DEFAULT '1', + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_query_history_star_user_id_query_uid` (`user_id`,`query_uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `quota` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `quota` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `org_id` bigint DEFAULT NULL, + `user_id` bigint DEFAULT NULL, + `target` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `limit` bigint NOT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_quota_org_id_user_id_target` (`org_id`,`user_id`,`target`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `recording_rules` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `recording_rules` ( + `id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `target_ref_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `org_id` bigint NOT NULL, + `interval` bigint NOT NULL, + `range` bigint NOT NULL, + `active` tinyint(1) NOT NULL DEFAULT '0', + `count` tinyint(1) NOT NULL DEFAULT '0', + `queries` blob NOT NULL, + `created_at` datetime NOT NULL, + `prom_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`id`,`target_ref_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `remote_write_targets` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `remote_write_targets` ( + `id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `data_source_uid` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `write_path` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `org_id` int NOT NULL, + PRIMARY KEY (`id`,`data_source_uid`,`write_path`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `report` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `report` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `org_id` bigint NOT NULL, + `dashboard_id` bigint NOT NULL, + `name` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `recipients` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `reply_to` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `message` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `schedule_frequency` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `schedule_day` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `schedule_hour` bigint NOT NULL, + `schedule_minute` bigint NOT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + `schedule_timezone` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'Europe/Stockholm', + `time_from` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `time_to` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `pdf_landscape` tinyint(1) DEFAULT NULL, + `schedule_day_of_month` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `pdf_layout` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `pdf_orientation` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `dashboard_uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `template_vars` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `enable_dashboard_url` tinyint(1) DEFAULT NULL, + `state` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `enable_csv` tinyint(1) DEFAULT NULL, + `schedule_start` int DEFAULT NULL, + `schedule_end` int DEFAULT NULL, + `schedule_interval_frequency` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `schedule_interval_amount` bigint DEFAULT NULL, + `schedule_workdays_only` tinyint(1) DEFAULT NULL, + `formats` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '["pdf"]', + `scale_factor` smallint NOT NULL DEFAULT '2', + `uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `pdf_show_template_variables` tinyint(1) NOT NULL DEFAULT '0', + `pdf_combine_one_file` tinyint(1) NOT NULL DEFAULT '1', + `subject` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_report_org_id_uid` (`org_id`,`uid`), + KEY `IDX_report_user_id` (`user_id`), + KEY `IDX_report_dashboard_id` (`dashboard_id`), + KEY `IDX_report_org_id` (`org_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `report_dashboards` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `report_dashboards` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `report_id` bigint NOT NULL, + `dashboard_uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `report_variables` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `time_to` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `time_from` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `IDX_report_dashboards_report_id` (`report_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `report_settings` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `report_settings` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `org_id` bigint NOT NULL, + `branding_report_logo_url` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `branding_email_logo_url` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `branding_email_footer_link` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `branding_email_footer_text` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `branding_email_footer_mode` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `pdf_theme` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'light', + `embedded_image_theme` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'dark', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `resource` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `resource` ( + `guid` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `resource_version` bigint DEFAULT NULL, + `group` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `resource` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `namespace` varchar(63) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `name` varchar(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `value` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `action` int NOT NULL, + `label_set` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `previous_resource_version` bigint DEFAULT NULL, + `folder` varchar(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + PRIMARY KEY (`guid`), + UNIQUE KEY `UQE_resource_namespace_group_resource_name` (`namespace`,`group`,`resource`,`name`), + KEY `IDX_resource_group_resource` (`group`,`resource`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `resource_blob` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `resource_blob` ( + `uuid` char(36) COLLATE utf8mb4_unicode_ci NOT NULL, + `created` datetime NOT NULL, + `group` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `resource` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `namespace` varchar(63) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `name` varchar(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `value` longblob NOT NULL, + `hash` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `content_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + PRIMARY KEY (`uuid`), + KEY `IDX_resource_history_namespace_group_name` (`namespace`,`group`,`resource`,`name`), + KEY `IDX_resource_blob_created` (`created`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `resource_events` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `resource_events` ( + `key_path` varchar(2048) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL, + `value` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + PRIMARY KEY (`key_path`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `resource_history` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `resource_history` ( + `guid` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `resource_version` bigint DEFAULT NULL, + `group` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `resource` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `namespace` varchar(63) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `name` varchar(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `value` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `action` int NOT NULL, + `label_set` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `previous_resource_version` bigint DEFAULT NULL, + `folder` varchar(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `generation` bigint NOT NULL DEFAULT '0', + `key_path` varchar(2048) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL DEFAULT '', + PRIMARY KEY (`guid`), + UNIQUE KEY `UQE_resource_history_namespace_group_name_version` (`namespace`,`group`,`resource`,`name`,`resource_version`), + KEY `IDX_resource_history_resource_version` (`resource_version`), + KEY `IDX_resource_history_group_resource_resource_version` (`group`,`resource`,`resource_version`), + KEY `IDX_resource_history_namespace_group_resource_action_version` (`namespace`,`group`,`resource`,`action`,`resource_version`), + KEY `IDX_resource_history_namespace_group_resource_name_generation` (`namespace`,`group`,`resource`,`name`,`generation`), + KEY `IDX_resource_history_key_path` (`key_path`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `resource_last_import_time` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `resource_last_import_time` ( + `group` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `resource` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `namespace` varchar(63) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `last_import_time` datetime NOT NULL, + PRIMARY KEY (`group`,`resource`,`namespace`), + KEY `UQE_resource_last_import_time_last_import_time` (`last_import_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `resource_migration_log` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `resource_migration_log` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `migration_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `sql` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `success` tinyint(1) NOT NULL, + `error` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `timestamp` datetime NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `resource_version` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `resource_version` ( + `group` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `resource` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `resource_version` bigint NOT NULL, + PRIMARY KEY (`group`,`resource`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `role` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `role` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `version` bigint NOT NULL, + `org_id` bigint NOT NULL, + `uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + `display_name` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `group_name` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `hidden` tinyint(1) NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_role_org_id_name` (`org_id`,`name`), + UNIQUE KEY `UQE_role_uid` (`uid`), + KEY `IDX_role_org_id` (`org_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `secret_data_key` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `secret_data_key` ( + `uid` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `namespace` varchar(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `label` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `active` tinyint(1) NOT NULL, + `provider` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `encrypted_data` blob NOT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + PRIMARY KEY (`uid`), + KEY `IDX_secret_data_key_namespace_label_active` (`namespace`,`label`,`active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `secret_encrypted_value` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `secret_encrypted_value` ( + `namespace` varchar(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `name` varchar(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `version` bigint NOT NULL, + `encrypted_data` blob NOT NULL, + `created` bigint NOT NULL, + `updated` bigint NOT NULL, + `data_key_id` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + PRIMARY KEY (`namespace`,`name`,`version`), + KEY `IDX_secret_encrypted_value_data_key_id` (`data_key_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `secret_keeper` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `secret_keeper` ( + `guid` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `name` varchar(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `namespace` varchar(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `annotations` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `labels` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `created` bigint NOT NULL, + `created_by` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `updated` bigint NOT NULL, + `updated_by` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `description` varchar(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `type` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `payload` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `active` tinyint(1) NOT NULL DEFAULT '0', + PRIMARY KEY (`guid`), + UNIQUE KEY `UQE_secret_keeper_namespace_name` (`namespace`,`name`), + KEY `IDX_secret_keeper_namespace_name_active` (`namespace`,`name`,`active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `secret_migration_log` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `secret_migration_log` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `migration_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `sql` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `success` tinyint(1) NOT NULL, + `error` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `timestamp` datetime NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `secret_secure_value` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `secret_secure_value` ( + `guid` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `name` varchar(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `namespace` varchar(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `annotations` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `labels` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `created` bigint NOT NULL, + `created_by` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `updated` bigint NOT NULL, + `updated_by` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `external_id` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `active` tinyint(1) NOT NULL, + `version` bigint NOT NULL, + `description` varchar(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `keeper` varchar(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `decrypters` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `ref` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `owner_reference_api_group` varchar(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `owner_reference_api_version` varchar(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `owner_reference_kind` varchar(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `owner_reference_name` varchar(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `lease_token` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `lease_created` bigint NOT NULL DEFAULT '0', + PRIMARY KEY (`guid`), + UNIQUE KEY `UQE_secret_secure_value_namespace_name_version_active` (`namespace`,`name`,`version`,`active`), + UNIQUE KEY `UQE_secret_secure_value_namespace_name_version` (`namespace`,`name`,`version`), + KEY `IDX_secret_secure_value_namespace_active_updated` (`namespace`,`active`,`updated`), + KEY `IDX_secret_secure_value_lease_token` (`lease_token`), + KEY `IDX_secret_secure_value_lease_created` (`lease_created`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `secrets` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `secrets` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `org_id` bigint NOT NULL, + `namespace` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `value` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `seed_assignment` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `seed_assignment` ( + `id` int NOT NULL AUTO_INCREMENT, + `builtin_role` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `role_name` varchar(190) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `action` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `scope` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `origin` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_seed_assignment_builtin_role_role_name` (`builtin_role`,`role_name`), + UNIQUE KEY `UQE_seed_assignment_builtin_role_action_scope` (`builtin_role`,`action`,`scope`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `server_lock` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `server_lock` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `operation_uid` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `version` bigint NOT NULL, + `last_execution` bigint NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_server_lock_operation_uid` (`operation_uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `session` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `session` ( + `key` char(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `data` blob NOT NULL, + `expiry` int NOT NULL, + PRIMARY KEY (`key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `setting` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `setting` ( + `section` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `key` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `value` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `encrypted_value` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + PRIMARY KEY (`section`,`key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `short_url` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `short_url` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `org_id` bigint NOT NULL, + `uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `path` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `created_by` bigint DEFAULT NULL, + `created_at` int NOT NULL, + `last_seen_at` int DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_short_url_org_id_uid` (`org_id`,`uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `signing_key` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `signing_key` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `key_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `private_key` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `added_at` datetime NOT NULL, + `expires_at` datetime DEFAULT NULL, + `alg` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_signing_key_key_id` (`key_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `sso_setting` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `sso_setting` ( + `id` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `provider` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `settings` mediumtext COLLATE utf8mb4_unicode_ci NOT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + `is_deleted` tinyint(1) NOT NULL DEFAULT '0', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `star` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `star` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `dashboard_id` bigint NOT NULL, + `dashboard_uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `org_id` bigint DEFAULT '1', + `updated` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_star_user_id_dashboard_id` (`user_id`,`dashboard_id`), + UNIQUE KEY `UQE_star_user_id_dashboard_uid_org_id` (`user_id`,`dashboard_uid`,`org_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `tag` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `tag` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `key` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `value` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_tag_key_value` (`key`,`value`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `team` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `team` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `org_id` bigint NOT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + `uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `external_uid` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `is_provisioned` tinyint(1) DEFAULT NULL, + `email` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_team_org_id_name` (`org_id`,`name`), + UNIQUE KEY `UQE_team_org_id_uid` (`org_id`,`uid`), + KEY `IDX_team_org_id` (`org_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `team_group` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `team_group` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `org_id` bigint NOT NULL, + `team_id` bigint NOT NULL, + `group_id` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + `uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_team_group_org_id_team_id_group_id` (`org_id`,`team_id`,`group_id`), + UNIQUE KEY `UQE_team_group_uid` (`uid`), + KEY `IDX_team_group_org_id` (`org_id`), + KEY `IDX_team_group_group_id` (`group_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `team_member` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `team_member` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `org_id` bigint NOT NULL, + `team_id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + `external` tinyint(1) DEFAULT NULL, + `permission` smallint DEFAULT NULL, + `uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_team_member_org_id_team_id_user_id` (`org_id`,`team_id`,`user_id`), + UNIQUE KEY `UQE_team_member_uid` (`uid`), + KEY `IDX_team_member_org_id` (`org_id`), + KEY `IDX_team_member_team_id` (`team_id`), + KEY `IDX_team_member_user_id_org_id` (`user_id`,`org_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `team_role` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `team_role` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `org_id` bigint NOT NULL, + `team_id` bigint NOT NULL, + `role_id` bigint NOT NULL, + `created` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_team_role_org_id_team_id_role_id` (`org_id`,`team_id`,`role_id`), + KEY `IDX_team_role_org_id` (`org_id`), + KEY `IDX_team_role_team_id` (`team_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `temp_user` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `temp_user` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `org_id` bigint NOT NULL, + `version` int NOT NULL, + `email` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `role` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `code` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `invited_by_user_id` bigint DEFAULT NULL, + `email_sent` tinyint(1) NOT NULL, + `email_sent_on` datetime DEFAULT NULL, + `remote_addr` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created` int NOT NULL DEFAULT '0', + `updated` int NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + KEY `IDX_temp_user_email` (`email`), + KEY `IDX_temp_user_org_id` (`org_id`), + KEY `IDX_temp_user_code` (`code`), + KEY `IDX_temp_user_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `test_data` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `test_data` ( + `id` int NOT NULL AUTO_INCREMENT, + `metric1` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `metric2` varchar(150) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `value_big_int` bigint DEFAULT NULL, + `value_double` double DEFAULT NULL, + `value_float` float DEFAULT NULL, + `value_int` int DEFAULT NULL, + `time_epoch` bigint NOT NULL, + `time_date_time` datetime NOT NULL, + `time_time_stamp` timestamp NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `unifiedstorage_migration_log` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `unifiedstorage_migration_log` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `migration_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `sql` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `success` tinyint(1) NOT NULL, + `error` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `timestamp` datetime NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `user` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `user` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `version` int NOT NULL, + `login` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `email` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `salt` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `rands` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `company` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `org_id` bigint NOT NULL, + `is_admin` tinyint(1) NOT NULL, + `email_verified` tinyint(1) DEFAULT NULL, + `theme` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + `help_flags1` bigint NOT NULL DEFAULT '0', + `last_seen_at` datetime DEFAULT NULL, + `is_disabled` tinyint(1) NOT NULL DEFAULT '0', + `is_service_account` tinyint(1) DEFAULT '0', + `uid` varchar(190) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `is_provisioned` tinyint(1) NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_user_login` (`login`), + UNIQUE KEY `UQE_user_email` (`email`), + UNIQUE KEY `UQE_user_uid` (`uid`), + KEY `IDX_user_login_email` (`login`,`email`), + KEY `IDX_user_is_service_account_last_seen_at` (`is_service_account`,`last_seen_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `user_auth` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `user_auth` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `auth_module` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `auth_id` varchar(190) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created` datetime NOT NULL, + `o_auth_access_token` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `o_auth_refresh_token` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `o_auth_token_type` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `o_auth_expiry` datetime DEFAULT NULL, + `o_auth_id_token` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `external_uid` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + PRIMARY KEY (`id`), + KEY `IDX_user_auth_auth_module_auth_id` (`auth_module`,`auth_id`), + KEY `IDX_user_auth_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `user_auth_token` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `user_auth_token` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `auth_token` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `prev_auth_token` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `user_agent` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `client_ip` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `auth_token_seen` tinyint(1) NOT NULL, + `seen_at` int DEFAULT NULL, + `rotated_at` int NOT NULL, + `created_at` int NOT NULL, + `updated_at` int NOT NULL, + `revoked_at` int DEFAULT NULL, + `external_session_id` bigint DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_user_auth_token_auth_token` (`auth_token`), + UNIQUE KEY `UQE_user_auth_token_prev_auth_token` (`prev_auth_token`), + KEY `IDX_user_auth_token_user_id` (`user_id`), + KEY `IDX_user_auth_token_revoked_at` (`revoked_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `user_dashboard_views` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `user_dashboard_views` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `dashboard_id` bigint NOT NULL, + `viewed` datetime NOT NULL, + `org_id` bigint DEFAULT NULL, + `dashboard_uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_user_dashboard_views_user_id_dashboard_id` (`user_id`,`dashboard_id`), + UNIQUE KEY `UQE_user_dashboard_views_user_id_org_id_dashboard_uid` (`user_id`,`org_id`,`dashboard_uid`), + KEY `IDX_user_dashboard_views_user_id` (`user_id`), + KEY `IDX_user_dashboard_views_dashboard_id` (`dashboard_id`), + KEY `IDX_user_dashboard_views_org_id_dashboard_uid` (`org_id`,`dashboard_uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `user_external_session` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `user_external_session` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_auth_id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `auth_module` varchar(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `access_token` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `id_token` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `refresh_token` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `session_id` varchar(1024) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL, + `session_id_hash` char(44) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `name_id` varchar(1024) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL, + `name_id_hash` char(44) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `expires_at` datetime DEFAULT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `user_role` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `user_role` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `org_id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `role_id` bigint NOT NULL, + `created` datetime NOT NULL, + `group_mapping_uid` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '', + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_user_role_org_id_user_id_role_id_group_mapping_uid` (`org_id`,`user_id`,`role_id`,`group_mapping_uid`), + KEY `IDX_user_role_org_id` (`org_id`), + KEY `IDX_user_role_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `user_stats` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `user_stats` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `billing_role` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `created` datetime NOT NULL, + `updated` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UQE_user_stats_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + diff --git a/pkg/services/sqlstore/migrator/snapshot/96-secret_migration_log.sql b/pkg/services/sqlstore/migrator/snapshot/96-secret_migration_log.sql new file mode 100644 index 00000000000..f5ae4ff6ab8 --- /dev/null +++ b/pkg/services/sqlstore/migrator/snapshot/96-secret_migration_log.sql @@ -0,0 +1,57 @@ +-- MySQL dump 10.13 Distrib 8.4.5, for Linux (x86_64) +-- +-- Host: localhost Database: hg_dump +-- ------------------------------------------------------ +-- Server version 8.4.5 +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- +-- Dumping data for table `secret_migration_log` +-- + +INSERT INTO `secret_migration_log` (`migration_id`, `sql`, `success`, `error`, `timestamp`) VALUES + ('create secret_migration_log table','CREATE TABLE IF NOT EXISTS `secret_migration_log` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `migration_id` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `sql` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `success` TINYINT(1) NOT NULL\n, `error` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `timestamp` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('Initialize secrets tables','',1,'','2022-01-01 00:00:00'), + ('drop table secret_secure_value','DROP TABLE IF EXISTS `secret_secure_value`',1,'','2022-01-01 00:00:00'), + ('create table secret_secure_value','CREATE TABLE IF NOT EXISTS `secret_secure_value` (\n`guid` VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci PRIMARY KEY NOT NULL\n, `name` VARCHAR(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `namespace` VARCHAR(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `annotations` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `labels` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `created` BIGINT(20) NOT NULL\n, `created_by` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `updated` BIGINT(20) NOT NULL\n, `updated_by` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `external_id` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `active` TINYINT(1) NOT NULL\n, `version` BIGINT(20) NOT NULL\n, `description` VARCHAR(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `keeper` VARCHAR(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `decrypters` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `ref` VARCHAR(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create table secret_secure_value, index: 0','CREATE UNIQUE INDEX `UQE_secret_secure_value_namespace_name_version_active` ON `secret_secure_value` (`namespace`,`name`,`version`,`active`);',1,'','2022-01-01 00:00:00'), + ('create table secret_secure_value, index: 1','CREATE UNIQUE INDEX `UQE_secret_secure_value_namespace_name_version` ON `secret_secure_value` (`namespace`,`name`,`version`);',1,'','2022-01-01 00:00:00'), + ('drop table secret_keeper','DROP TABLE IF EXISTS `secret_keeper`',1,'','2022-01-01 00:00:00'), + ('create table secret_keeper','CREATE TABLE IF NOT EXISTS `secret_keeper` (\n`guid` VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci PRIMARY KEY NOT NULL\n, `name` VARCHAR(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `namespace` VARCHAR(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `annotations` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `labels` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `created` BIGINT(20) NOT NULL\n, `created_by` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `updated` BIGINT(20) NOT NULL\n, `updated_by` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `description` VARCHAR(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `type` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `payload` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create table secret_keeper, index: 0','CREATE UNIQUE INDEX `UQE_secret_keeper_namespace_name` ON `secret_keeper` (`namespace`,`name`);',1,'','2022-01-01 00:00:00'), + ('drop table secret_data_key','DROP TABLE IF EXISTS `secret_data_key`',1,'','2022-01-01 00:00:00'), + ('create table secret_data_key','CREATE TABLE IF NOT EXISTS `secret_data_key` (\n`uid` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci PRIMARY KEY NOT NULL\n, `namespace` VARCHAR(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `label` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `active` TINYINT(1) NOT NULL\n, `provider` VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `encrypted_data` BLOB NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('drop table secret_encrypted_value','DROP TABLE IF EXISTS `secret_encrypted_value`',1,'','2022-01-01 00:00:00'), + ('create table secret_encrypted_value','CREATE TABLE IF NOT EXISTS `secret_encrypted_value` (\n`namespace` VARCHAR(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `name` VARCHAR(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `version` BIGINT(20) NOT NULL\n, `encrypted_data` BLOB NOT NULL\n, `created` BIGINT(20) NOT NULL\n, `updated` BIGINT(20) NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create table secret_encrypted_value, index: 0','CREATE UNIQUE INDEX `UQE_secret_encrypted_value_namespace_name_version` ON `secret_encrypted_value` (`namespace`,`name`,`version`);',1,'','2022-01-01 00:00:00'), + ('create index for list on secret_secure_value','CREATE INDEX `IDX_secret_secure_value_namespace_active_updated` ON `secret_secure_value` (`namespace`,`active`,`updated`);',1,'','2022-01-01 00:00:00'), + ('create index for list and read current on secret_data_key','CREATE INDEX `IDX_secret_data_key_namespace_label_active` ON `secret_data_key` (`namespace`,`label`,`active`);',1,'','2022-01-01 00:00:00'), + ('add owner_reference_api_group column to secret_secure_value','alter table `secret_secure_value` ADD COLUMN `owner_reference_api_group` VARCHAR(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add owner_reference_api_version column to secret_secure_value','alter table `secret_secure_value` ADD COLUMN `owner_reference_api_version` VARCHAR(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add owner_reference_kind column to secret_secure_value','alter table `secret_secure_value` ADD COLUMN `owner_reference_kind` VARCHAR(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add owner_reference_name column to secret_secure_value','alter table `secret_secure_value` ADD COLUMN `owner_reference_name` VARCHAR(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add lease_token column to secret_secure_value','alter table `secret_secure_value` ADD COLUMN `lease_token` VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add lease_token index to secret_secure_value','CREATE INDEX `IDX_secret_secure_value_lease_token` ON `secret_secure_value` (`lease_token`);',1,'','2022-01-01 00:00:00'), + ('add lease_created column to secret_secure_value','alter table `secret_secure_value` ADD COLUMN `lease_created` BIGINT(20) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('add lease_created index to secret_secure_value','CREATE INDEX `IDX_secret_secure_value_lease_created` ON `secret_secure_value` (`lease_created`);',1,'','2022-01-01 00:00:00'), + ('add data_key_id column to secret_encrypted_value','alter table `secret_encrypted_value` ADD COLUMN `data_key_id` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT \'\' ',1,'','2022-01-01 00:00:00'), + ('add data_key_id index to secret_encrypted_value','CREATE INDEX `IDX_secret_encrypted_value_data_key_id` ON `secret_encrypted_value` (`data_key_id`);',1,'','2022-01-01 00:00:00'), + ('add active column to secret_keeper','alter table `secret_keeper` ADD COLUMN `active` TINYINT(1) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('add active column index to secret_keeper','CREATE INDEX `IDX_secret_keeper_namespace_name_active` ON `secret_keeper` (`namespace`,`name`,`active`);',1,'','2022-01-01 00:00:00'), + ('set secret_secure_value.keeper to \'system\' where keeper is null in secret_secure_value','UPDATE secret_secure_value SET keeper = \'system\' WHERE keeper IS NULL',1,'','2022-01-01 00:00:00'), + ('drop my_row_id and add primary key with columns namespace,name,version to table secret_encrypted_value if my_row_id exists (auto-generated mysql column)','\n ALTER TABLE secret_encrypted_value\n DROP PRIMARY KEY,\n DROP COLUMN my_row_id,\n DROP INDEX UQE_secret_encrypted_value_namespace_name_version,\n ADD PRIMARY KEY (`namespace`,`name`,`version`);\n ',1,'','2022-01-01 00:00:00'), + ('drop unique index UQE_secret_encrypted_value_namespace_name_version from secret_encrypted_value table if it exists (mysql)','ALTER TABLE secret_encrypted_value DROP INDEX UQE_secret_encrypted_value_namespace_name_version',1,'','2022-01-01 00:00:00'), + ('add primary key with columns namespace,name,version to table secret_encrypted_value if it doesn\'t exist (mysql)','ALTER TABLE secret_encrypted_value ADD PRIMARY KEY (`namespace`,`name`,`version`)',1,'','2022-01-01 00:00:00'), + ('add primary key with columns namespace,name,version to table secret_encrypted_value (postgres and sqlite)','',1,'','2022-01-01 00:00:00'); +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + diff --git a/pkg/services/sqlstore/migrator/snapshot/97-resource_migration_log.sql b/pkg/services/sqlstore/migrator/snapshot/97-resource_migration_log.sql new file mode 100644 index 00000000000..35327ae4531 --- /dev/null +++ b/pkg/services/sqlstore/migrator/snapshot/97-resource_migration_log.sql @@ -0,0 +1,60 @@ +-- MySQL dump 10.13 Distrib 8.4.5, for Linux (x86_64) +-- +-- Host: localhost Database: hg_dump +-- ------------------------------------------------------ +-- Server version 8.4.5 +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- +-- Dumping data for table `resource_migration_log` +-- + +INSERT INTO `resource_migration_log` (`migration_id`, `sql`, `success`, `error`, `timestamp`) VALUES + ('create resource_migration_log table','CREATE TABLE IF NOT EXISTS `resource_migration_log` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `migration_id` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `sql` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `success` TINYINT(1) NOT NULL\n, `error` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `timestamp` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('Initialize resource tables','',1,'','2022-01-01 00:00:00'), + ('drop table resource','DROP TABLE IF EXISTS `resource`',1,'','2022-01-01 00:00:00'), + ('create table resource','CREATE TABLE IF NOT EXISTS `resource` (\n`guid` VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci PRIMARY KEY NOT NULL\n, `resource_version` BIGINT(20) NULL\n, `group` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `resource` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `namespace` VARCHAR(63) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `name` VARCHAR(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `value` LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `action` INT NOT NULL\n, `label_set` VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create table resource, index: 0','CREATE UNIQUE INDEX `UQE_resource_namespace_group_resource_name` ON `resource` (`namespace`,`group`,`resource`,`name`);',1,'','2022-01-01 00:00:00'), + ('drop table resource_history','DROP TABLE IF EXISTS `resource_history`',1,'','2022-01-01 00:00:00'), + ('create table resource_history','CREATE TABLE IF NOT EXISTS `resource_history` (\n`guid` VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci PRIMARY KEY NOT NULL\n, `resource_version` BIGINT(20) NULL\n, `group` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `resource` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `namespace` VARCHAR(63) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `name` VARCHAR(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `value` LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `action` INT NOT NULL\n, `label_set` VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create table resource_history, index: 0','CREATE UNIQUE INDEX `UQE_resource_history_namespace_group_name_version` ON `resource_history` (`namespace`,`group`,`resource`,`name`,`resource_version`);',1,'','2022-01-01 00:00:00'), + ('create table resource_history, index: 1','CREATE INDEX `IDX_resource_history_resource_version` ON `resource_history` (`resource_version`);',1,'','2022-01-01 00:00:00'), + ('drop table resource_version','DROP TABLE IF EXISTS `resource_version`',1,'','2022-01-01 00:00:00'), + ('create table resource_version','CREATE TABLE IF NOT EXISTS `resource_version` (\n`group` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `resource` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `resource_version` BIGINT(20) NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create table resource_version, index: 0','CREATE UNIQUE INDEX `UQE_resource_version_group_resource` ON `resource_version` (`group`,`resource`);',1,'','2022-01-01 00:00:00'), + ('drop table resource_blob','DROP TABLE IF EXISTS `resource_blob`',1,'','2022-01-01 00:00:00'), + ('create table resource_blob','CREATE TABLE IF NOT EXISTS `resource_blob` (\n`uuid` CHAR(36) PRIMARY KEY NOT NULL\n, `created` DATETIME NOT NULL\n, `group` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `resource` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `namespace` VARCHAR(63) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `name` VARCHAR(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `value` LONGBLOB NOT NULL\n, `hash` VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `content_type` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create table resource_blob, index: 0','CREATE INDEX `IDX_resource_history_namespace_group_name` ON `resource_blob` (`namespace`,`group`,`resource`,`name`);',1,'','2022-01-01 00:00:00'), + ('create table resource_blob, index: 1','CREATE INDEX `IDX_resource_blob_created` ON `resource_blob` (`created`);',1,'','2022-01-01 00:00:00'), + ('drop table resource_last_import_time','DROP TABLE IF EXISTS `resource_last_import_time`',1,'','2022-01-01 00:00:00'), + ('create table resource_last_import_time','CREATE TABLE IF NOT EXISTS `resource_last_import_time` (\n`group` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `resource` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `namespace` VARCHAR(63) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `last_import_time` DATETIME NOT NULL\n, PRIMARY KEY ( `group`,`resource`,`namespace` )) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('Add column previous_resource_version in resource_history','alter table `resource_history` ADD COLUMN `previous_resource_version` BIGINT(20) NULL ',1,'','2022-01-01 00:00:00'), + ('Add column previous_resource_version in resource','alter table `resource` ADD COLUMN `previous_resource_version` BIGINT(20) NULL ',1,'','2022-01-01 00:00:00'), + ('Add index to resource_history for polling','CREATE INDEX `IDX_resource_history_group_resource_resource_version` ON `resource_history` (`group`,`resource`,`resource_version`);',1,'','2022-01-01 00:00:00'), + ('Add index to resource for loading','CREATE INDEX `IDX_resource_group_resource` ON `resource` (`group`,`resource`);',1,'','2022-01-01 00:00:00'), + ('Add column folder in resource_history','alter table `resource_history` ADD COLUMN `folder` VARCHAR(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT \'\' ',1,'','2022-01-01 00:00:00'), + ('Add column folder in resource','alter table `resource` ADD COLUMN `folder` VARCHAR(253) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT \'\' ',1,'','2022-01-01 00:00:00'), + ('Migrate DeletionMarkers to real Resource objects','Find rows in resource_history with value LIKE {\"kind\":\"DeletedMarker\"%',1,'','2022-01-01 00:00:00'), + ('Add index to resource_history for get trash','CREATE INDEX `IDX_resource_history_namespace_group_resource_action_version` ON `resource_history` (`namespace`,`group`,`resource`,`action`,`resource_version`);',1,'','2022-01-01 00:00:00'), + ('Add generation to resource history','alter table `resource_history` ADD COLUMN `generation` BIGINT(20) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('Add generation index to resource history','CREATE INDEX `IDX_resource_history_namespace_group_resource_name_generation` ON `resource_history` (`namespace`,`group`,`resource`,`name`,`generation`);',1,'','2022-01-01 00:00:00'), + ('Add UQE_resource_last_import_time_last_import_time index','CREATE INDEX `UQE_resource_last_import_time_last_import_time` ON `resource_last_import_time` (`last_import_time`);',1,'','2022-01-01 00:00:00'), + ('Add key_path column to resource_history','alter table `resource_history` ADD COLUMN `key_path` VARCHAR(2048) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL DEFAULT \'\' ',1,'','2022-01-01 00:00:00'), + ('create table resource_events','CREATE TABLE IF NOT EXISTS `resource_events` (\n`key_path` VARCHAR(2048) CHARACTER SET latin1 COLLATE latin1_bin PRIMARY KEY NOT NULL\n, `value` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('Add IDX_resource_history_key_path index','CREATE INDEX `IDX_resource_history_key_path` ON `resource_history` (`key_path`);',1,'','2022-01-01 00:00:00'), + ('drop my_row_id and add primary key with columns group,resource to table resource_version if my_row_id exists (auto-generated mysql column)','\n ALTER TABLE resource_version\n DROP PRIMARY KEY,\n DROP COLUMN my_row_id,\n DROP INDEX UQE_resource_version_group_resource,\n ADD PRIMARY KEY (`group`,`resource`);\n ',1,'','2022-01-01 00:00:00'), + ('drop unique index UQE_resource_version_group_resource from resource_version table if it exists (mysql)','ALTER TABLE resource_version DROP INDEX UQE_resource_version_group_resource',1,'','2022-01-01 00:00:00'), + ('add primary key with columns group,resource to table resource_version if it doesn\'t exist (mysql)','ALTER TABLE resource_version ADD PRIMARY KEY (`group`,`resource`)',1,'','2022-01-01 00:00:00'), + ('add primary key with columns group,resource to table resource_version (postgres and sqlite)','',1,'','2022-01-01 00:00:00'); +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + diff --git a/pkg/services/sqlstore/migrator/snapshot/98-data.sql b/pkg/services/sqlstore/migrator/snapshot/98-data.sql new file mode 100644 index 00000000000..de4d2e04323 --- /dev/null +++ b/pkg/services/sqlstore/migrator/snapshot/98-data.sql @@ -0,0 +1,469 @@ +-- MySQL dump 10.13 Distrib 8.4.5, for Linux (x86_64) +-- +-- Host: localhost Database: hg_dump +-- ------------------------------------------------------ +-- Server version 8.4.5 +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- +-- Dumping data for table `alert` +-- + + +-- +-- Dumping data for table `alert_image` +-- + + +-- +-- Dumping data for table `alert_instance` +-- + + +-- +-- Dumping data for table `alert_notification` +-- + + +-- +-- Dumping data for table `alert_notification_state` +-- + + +-- +-- Dumping data for table `alert_rule` +-- + + +-- +-- Dumping data for table `alert_rule_state` +-- + + +-- +-- Dumping data for table `alert_rule_tag` +-- + + +-- +-- Dumping data for table `alert_rule_version` +-- + + +-- +-- Dumping data for table `annotation` +-- + + +-- +-- Dumping data for table `annotation_tag` +-- + + +-- +-- Dumping data for table `anon_device` +-- + + +-- +-- Dumping data for table `api_key` +-- + + +-- +-- Dumping data for table `cache_data` +-- + + +-- +-- Dumping data for table `cloud_migration_resource` +-- + + +-- +-- Dumping data for table `cloud_migration_session` +-- + + +-- +-- Dumping data for table `cloud_migration_snapshot` +-- + + +-- +-- Dumping data for table `cloud_migration_snapshot_partition` +-- + + +-- +-- Dumping data for table `correlation` +-- + + +-- +-- Dumping data for table `dashboard` +-- + + +-- +-- Dumping data for table `dashboard_acl` +-- + +INSERT INTO `dashboard_acl` (`id`, `org_id`, `dashboard_id`, `user_id`, `team_id`, `permission`, `role`, `created`, `updated`) VALUES + (1,-1,-1,NULL,NULL,1,'Viewer','2022-01-01 00:00:00','2022-01-01 00:00:00'), + (2,-1,-1,NULL,NULL,2,'Editor','2022-01-01 00:00:00','2022-01-01 00:00:00'); + +-- +-- Dumping data for table `dashboard_provisioning` +-- + + +-- +-- Dumping data for table `dashboard_public` +-- + + +-- +-- Dumping data for table `dashboard_public_email_share` +-- + + +-- +-- Dumping data for table `dashboard_public_magic_link` +-- + + +-- +-- Dumping data for table `dashboard_public_session` +-- + + +-- +-- Dumping data for table `dashboard_public_usage_by_day` +-- + + +-- +-- Dumping data for table `dashboard_snapshot` +-- + + +-- +-- Dumping data for table `dashboard_tag` +-- + + +-- +-- Dumping data for table `dashboard_usage_by_day` +-- + + +-- +-- Dumping data for table `dashboard_usage_sums` +-- + + +-- +-- Dumping data for table `dashboard_version` +-- + + +-- +-- Dumping data for table `data_keys` +-- + + +-- +-- Dumping data for table `data_source` +-- + + +-- +-- Dumping data for table `data_source_acl` +-- + + +-- +-- Dumping data for table `data_source_cache` +-- + + +-- +-- Dumping data for table `data_source_usage_by_day` +-- + + +-- +-- Dumping data for table `entity_event` +-- + + +-- +-- Dumping data for table `file` +-- + + +-- +-- Dumping data for table `file_meta` +-- + + +-- +-- Dumping data for table `folder` +-- + + +-- +-- Dumping data for table `library_element` +-- + + +-- +-- Dumping data for table `library_element_connection` +-- + + +-- +-- Dumping data for table `license_token` +-- + + +-- +-- Dumping data for table `login_attempt` +-- + + +-- +-- Dumping data for table `ngalert_configuration` +-- + + +-- +-- Dumping data for table `playlist` +-- + + +-- +-- Dumping data for table `playlist_item` +-- + + +-- +-- Dumping data for table `plugin_setting` +-- + + +-- +-- Dumping data for table `preferences` +-- + + +-- +-- Dumping data for table `provenance_type` +-- + + +-- +-- Dumping data for table `query_history` +-- + + +-- +-- Dumping data for table `query_history_details` +-- + + +-- +-- Dumping data for table `query_history_star` +-- + + +-- +-- Dumping data for table `quota` +-- + + +-- +-- Dumping data for table `recording_rules` +-- + + +-- +-- Dumping data for table `remote_write_targets` +-- + + +-- +-- Dumping data for table `report` +-- + + +-- +-- Dumping data for table `report_dashboards` +-- + + +-- +-- Dumping data for table `report_settings` +-- + + +-- +-- Dumping data for table `resource` +-- + + +-- +-- Dumping data for table `resource_blob` +-- + + +-- +-- Dumping data for table `resource_events` +-- + + +-- +-- Dumping data for table `resource_history` +-- + + +-- +-- Dumping data for table `secret_data_key` +-- + + +-- +-- Dumping data for table `secret_encrypted_value` +-- + + +-- +-- Dumping data for table `secret_keeper` +-- + + +-- +-- Dumping data for table `secret_secure_value` +-- + + +-- +-- Dumping data for table `secrets` +-- + + +-- +-- Dumping data for table `session` +-- + + +-- +-- Dumping data for table `setting` +-- + + +-- +-- Dumping data for table `short_url` +-- + + +-- +-- Dumping data for table `signing_key` +-- + + +-- +-- Dumping data for table `sso_setting` +-- + + +-- +-- Dumping data for table `star` +-- + + +-- +-- Dumping data for table `tag` +-- + + +-- +-- Dumping data for table `team` +-- + + +-- +-- Dumping data for table `team_group` +-- + + +-- +-- Dumping data for table `team_member` +-- + + +-- +-- Dumping data for table `team_role` +-- + + +-- +-- Dumping data for table `temp_user` +-- + + +-- +-- Dumping data for table `test_data` +-- + + +-- +-- Dumping data for table `unifiedstorage_migration_log` +-- + +INSERT INTO `unifiedstorage_migration_log` (`id`, `migration_id`, `sql`, `success`, `error`, `timestamp`) VALUES + (1,'create unifiedstorage_migration_log table','CREATE TABLE IF NOT EXISTS `unifiedstorage_migration_log` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `migration_id` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `sql` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `success` TINYINT(1) NOT NULL\n, `error` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `timestamp` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + (2,'playlists migration','unified storage data migration: playlists',1,'','2022-01-01 00:00:00'); + +-- +-- Dumping data for table `user_auth` +-- + + +-- +-- Dumping data for table `user_auth_token` +-- + + +-- +-- Dumping data for table `user_dashboard_views` +-- + + +-- +-- Dumping data for table `user_external_session` +-- + + +-- +-- Dumping data for table `user_role` +-- + + +-- +-- Dumping data for table `user_stats` +-- + +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + diff --git a/pkg/services/sqlstore/migrator/snapshot/99-migration_log.sql b/pkg/services/sqlstore/migrator/snapshot/99-migration_log.sql new file mode 100644 index 00000000000..b4900f95f13 --- /dev/null +++ b/pkg/services/sqlstore/migrator/snapshot/99-migration_log.sql @@ -0,0 +1,850 @@ +-- MySQL dump 10.13 Distrib 8.4.5, for Linux (x86_64) +-- +-- Host: localhost Database: hg_dump +-- ------------------------------------------------------ +-- Server version 8.4.5 +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- +-- Dumping data for table `migration_log` +-- + +INSERT INTO `migration_log` (`migration_id`, `sql`, `success`, `error`, `timestamp`) VALUES + ('create migration_log table','CREATE TABLE IF NOT EXISTS `migration_log` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `migration_id` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `sql` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `success` TINYINT(1) NOT NULL\n, `error` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `timestamp` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create user table','CREATE TABLE IF NOT EXISTS `user` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `version` INT NOT NULL\n, `login` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `email` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `name` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `password` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `salt` VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `rands` VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `company` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `account_id` BIGINT(20) NOT NULL\n, `is_admin` TINYINT(1) NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add unique index user.login','CREATE UNIQUE INDEX `UQE_user_login` ON `user` (`login`);',1,'','2022-01-01 00:00:00'), + ('add unique index user.email','CREATE UNIQUE INDEX `UQE_user_email` ON `user` (`email`);',1,'','2022-01-01 00:00:00'), + ('drop index UQE_user_login - v1','DROP INDEX `UQE_user_login` ON `user`',1,'','2022-01-01 00:00:00'), + ('drop index UQE_user_email - v1','DROP INDEX `UQE_user_email` ON `user`',1,'','2022-01-01 00:00:00'), + ('Rename table user to user_v1 - v1','ALTER TABLE `user` RENAME TO `user_v1`',1,'','2022-01-01 00:00:00'), + ('create user table v2','CREATE TABLE IF NOT EXISTS `user` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `version` INT NOT NULL\n, `login` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `email` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `name` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `password` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `salt` VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `rands` VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `company` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `org_id` BIGINT(20) NOT NULL\n, `is_admin` TINYINT(1) NOT NULL\n, `email_verified` TINYINT(1) NULL\n, `theme` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create index UQE_user_login - v2','CREATE UNIQUE INDEX `UQE_user_login` ON `user` (`login`);',1,'','2022-01-01 00:00:00'), + ('create index UQE_user_email - v2','CREATE UNIQUE INDEX `UQE_user_email` ON `user` (`email`);',1,'','2022-01-01 00:00:00'), + ('copy data_source v1 to v2','INSERT INTO `user` (`version`\n, `email`\n, `name`\n, `password`\n, `is_admin`\n, `id`\n, `login`\n, `salt`\n, `rands`\n, `company`\n, `org_id`\n, `created`\n, `updated`)\nSELECT `version`\n, `email`\n, `name`\n, `password`\n, `is_admin`\n, `id`\n, `login`\n, `salt`\n, `rands`\n, `company`\n, `account_id`\n, `created`\n, `updated`\nFROM `user_v1`',1,'','2022-01-01 00:00:00'), + ('Drop old table user_v1','DROP TABLE IF EXISTS `user_v1`',1,'','2022-01-01 00:00:00'), + ('Add column help_flags1 to user table','alter table `user` ADD COLUMN `help_flags1` BIGINT(20) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('Update user table charset','ALTER TABLE `user` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, MODIFY `login` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `email` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `name` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL , MODIFY `password` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL , MODIFY `salt` VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL , MODIFY `rands` VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL , MODIFY `company` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL , MODIFY `theme` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ;',1,'','2022-01-01 00:00:00'), + ('Add last_seen_at column to user','alter table `user` ADD COLUMN `last_seen_at` DATETIME NULL ',1,'','2022-01-01 00:00:00'), + ('Add missing user data','code migration',1,'','2022-01-01 00:00:00'), + ('Add is_disabled column to user','alter table `user` ADD COLUMN `is_disabled` TINYINT(1) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('Add index user.login/user.email','CREATE INDEX `IDX_user_login_email` ON `user` (`login`,`email`);',1,'','2022-01-01 00:00:00'), + ('Add is_service_account column to user','alter table `user` ADD COLUMN `is_service_account` TINYINT(1) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('Update is_service_account column to nullable','ALTER TABLE user MODIFY is_service_account BOOLEAN DEFAULT 0;',1,'','2022-01-01 00:00:00'), + ('Add uid column to user','alter table `user` ADD COLUMN `uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Update uid column values for users','UPDATE user SET uid=concat(\'u\',lpad(id,9,\'0\')) WHERE uid IS NULL;',1,'','2022-01-01 00:00:00'), + ('Make sure users uid are set','UPDATE user SET uid=concat(\'u\',lpad(id,9,\'0\')) WHERE uid is NULL OR uid = \'\';',1,'','2022-01-01 00:00:00'), + ('Add unique index user_uid','CREATE UNIQUE INDEX `UQE_user_uid` ON `user` (`uid`);',1,'','2022-01-01 00:00:00'), + ('Add is_provisioned column to user','alter table `user` ADD COLUMN `is_provisioned` TINYINT(1) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('update login field with orgid to allow for multiple service accounts with same name across orgs','code migration',1,'','2022-01-01 00:00:00'), + ('update service accounts login field orgid to appear only once','code migration',1,'','2022-01-01 00:00:00'), + ('update login and email fields to lowercase','code migration',1,'','2022-01-01 00:00:00'), + ('update login and email fields to lowercase2','code migration',1,'','2022-01-01 00:00:00'), + ('Add index on user.is_service_account and user.last_seen_at','CREATE INDEX `IDX_user_is_service_account_last_seen_at` ON `user` (`is_service_account`,`last_seen_at`);',1,'','2022-01-01 00:00:00'), + ('Expand user.uid length to 190','ALTER TABLE user MODIFY uid VARCHAR(190);',1,'','2022-01-01 00:00:00'), + ('Prefix SCIM uid for provisioned users','UPDATE user SET uid = CONCAT(\'scim-\', uid) WHERE is_provisioned = 1 AND uid NOT LIKE \'scim-%\';',1,'','2022-01-01 00:00:00'), + ('create temp user table v1-7','CREATE TABLE IF NOT EXISTS `temp_user` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `version` INT NOT NULL\n, `email` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `name` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `role` VARCHAR(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `code` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `status` VARCHAR(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `invited_by_user_id` BIGINT(20) NULL\n, `email_sent` TINYINT(1) NOT NULL\n, `email_sent_on` DATETIME NULL\n, `remote_addr` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create index IDX_temp_user_email - v1-7','CREATE INDEX `IDX_temp_user_email` ON `temp_user` (`email`);',1,'','2022-01-01 00:00:00'), + ('create index IDX_temp_user_org_id - v1-7','CREATE INDEX `IDX_temp_user_org_id` ON `temp_user` (`org_id`);',1,'','2022-01-01 00:00:00'), + ('create index IDX_temp_user_code - v1-7','CREATE INDEX `IDX_temp_user_code` ON `temp_user` (`code`);',1,'','2022-01-01 00:00:00'), + ('create index IDX_temp_user_status - v1-7','CREATE INDEX `IDX_temp_user_status` ON `temp_user` (`status`);',1,'','2022-01-01 00:00:00'), + ('Update temp_user table charset','ALTER TABLE `temp_user` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, MODIFY `email` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `name` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL , MODIFY `role` VARCHAR(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL , MODIFY `code` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `status` VARCHAR(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `remote_addr` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ;',1,'','2022-01-01 00:00:00'), + ('drop index IDX_temp_user_email - v1','DROP INDEX `IDX_temp_user_email` ON `temp_user`',1,'','2022-01-01 00:00:00'), + ('drop index IDX_temp_user_org_id - v1','DROP INDEX `IDX_temp_user_org_id` ON `temp_user`',1,'','2022-01-01 00:00:00'), + ('drop index IDX_temp_user_code - v1','DROP INDEX `IDX_temp_user_code` ON `temp_user`',1,'','2022-01-01 00:00:00'), + ('drop index IDX_temp_user_status - v1','DROP INDEX `IDX_temp_user_status` ON `temp_user`',1,'','2022-01-01 00:00:00'), + ('Rename table temp_user to temp_user_tmp_qwerty - v1','ALTER TABLE `temp_user` RENAME TO `temp_user_tmp_qwerty`',1,'','2022-01-01 00:00:00'), + ('create temp_user v2','CREATE TABLE IF NOT EXISTS `temp_user` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `version` INT NOT NULL\n, `email` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `name` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `role` VARCHAR(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `code` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `status` VARCHAR(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `invited_by_user_id` BIGINT(20) NULL\n, `email_sent` TINYINT(1) NOT NULL\n, `email_sent_on` DATETIME NULL\n, `remote_addr` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `created` INT NOT NULL DEFAULT 0\n, `updated` INT NOT NULL DEFAULT 0\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create index IDX_temp_user_email - v2','CREATE INDEX `IDX_temp_user_email` ON `temp_user` (`email`);',1,'','2022-01-01 00:00:00'), + ('create index IDX_temp_user_org_id - v2','CREATE INDEX `IDX_temp_user_org_id` ON `temp_user` (`org_id`);',1,'','2022-01-01 00:00:00'), + ('create index IDX_temp_user_code - v2','CREATE INDEX `IDX_temp_user_code` ON `temp_user` (`code`);',1,'','2022-01-01 00:00:00'), + ('create index IDX_temp_user_status - v2','CREATE INDEX `IDX_temp_user_status` ON `temp_user` (`status`);',1,'','2022-01-01 00:00:00'), + ('copy temp_user v1 to v2','INSERT INTO `temp_user` (`invited_by_user_id`\n, `email_sent`\n, `email_sent_on`\n, `org_id`\n, `version`\n, `email`\n, `name`\n, `code`\n, `status`\n, `remote_addr`\n, `id`\n, `role`)\nSELECT `invited_by_user_id`\n, `email_sent`\n, `email_sent_on`\n, `org_id`\n, `version`\n, `email`\n, `name`\n, `code`\n, `status`\n, `remote_addr`\n, `id`\n, `role`\nFROM `temp_user_tmp_qwerty`',1,'','2022-01-01 00:00:00'), + ('drop temp_user_tmp_qwerty','DROP TABLE IF EXISTS `temp_user_tmp_qwerty`',1,'','2022-01-01 00:00:00'), + ('Set created for temp users that will otherwise prematurely expire','code migration',1,'','2022-01-01 00:00:00'), + ('create star table','CREATE TABLE IF NOT EXISTS `star` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `user_id` BIGINT(20) NOT NULL\n, `dashboard_id` BIGINT(20) NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add unique index star.user_id_dashboard_id','CREATE UNIQUE INDEX `UQE_star_user_id_dashboard_id` ON `star` (`user_id`,`dashboard_id`);',1,'','2022-01-01 00:00:00'), + ('Add column dashboard_uid in star','alter table `star` ADD COLUMN `dashboard_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Add column org_id in star','alter table `star` ADD COLUMN `org_id` BIGINT(20) NULL DEFAULT 1 ',1,'','2022-01-01 00:00:00'), + ('Add column updated in star','alter table `star` ADD COLUMN `updated` DATETIME NULL ',1,'','2022-01-01 00:00:00'), + ('add index in star table on dashboard_uid, org_id and user_id columns','CREATE UNIQUE INDEX `UQE_star_user_id_dashboard_uid_org_id` ON `star` (`user_id`,`dashboard_uid`,`org_id`);',1,'','2022-01-01 00:00:00'), + ('create org table v1','CREATE TABLE IF NOT EXISTS `org` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `version` INT NOT NULL\n, `name` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `address1` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `address2` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `city` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `state` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `zip_code` VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `country` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `billing_email` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create index UQE_org_name - v1','CREATE UNIQUE INDEX `UQE_org_name` ON `org` (`name`);',1,'','2022-01-01 00:00:00'), + ('create org_user table v1','CREATE TABLE IF NOT EXISTS `org_user` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `user_id` BIGINT(20) NOT NULL\n, `role` VARCHAR(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create index IDX_org_user_org_id - v1','CREATE INDEX `IDX_org_user_org_id` ON `org_user` (`org_id`);',1,'','2022-01-01 00:00:00'), + ('create index UQE_org_user_org_id_user_id - v1','CREATE UNIQUE INDEX `UQE_org_user_org_id_user_id` ON `org_user` (`org_id`,`user_id`);',1,'','2022-01-01 00:00:00'), + ('create index IDX_org_user_user_id - v1','CREATE INDEX `IDX_org_user_user_id` ON `org_user` (`user_id`);',1,'','2022-01-01 00:00:00'), + ('Update org table charset','ALTER TABLE `org` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, MODIFY `name` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `address1` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL , MODIFY `address2` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL , MODIFY `city` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL , MODIFY `state` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL , MODIFY `zip_code` VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL , MODIFY `country` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL , MODIFY `billing_email` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ;',1,'','2022-01-01 00:00:00'), + ('Update org_user table charset','ALTER TABLE `org_user` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, MODIFY `role` VARCHAR(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL ;',1,'','2022-01-01 00:00:00'), + ('Migrate all Read Only Viewers to Viewers','UPDATE org_user SET role = \'Viewer\' WHERE role = \'Read Only Editor\'',1,'','2022-01-01 00:00:00'), + ('create dashboard table','CREATE TABLE IF NOT EXISTS `dashboard` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `version` INT NOT NULL\n, `slug` VARCHAR(189) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `title` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `data` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `account_id` BIGINT(20) NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index dashboard.account_id','CREATE INDEX `IDX_dashboard_account_id` ON `dashboard` (`account_id`);',1,'','2022-01-01 00:00:00'), + ('add unique index dashboard_account_id_slug','CREATE UNIQUE INDEX `UQE_dashboard_account_id_slug` ON `dashboard` (`account_id`,`slug`);',1,'','2022-01-01 00:00:00'), + ('create dashboard_tag table','CREATE TABLE IF NOT EXISTS `dashboard_tag` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `dashboard_id` BIGINT(20) NOT NULL\n, `term` VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add unique index dashboard_tag.dasboard_id_term','CREATE UNIQUE INDEX `UQE_dashboard_tag_dashboard_id_term` ON `dashboard_tag` (`dashboard_id`,`term`);',1,'','2022-01-01 00:00:00'), + ('drop index UQE_dashboard_tag_dashboard_id_term - v1','DROP INDEX `UQE_dashboard_tag_dashboard_id_term` ON `dashboard_tag`',1,'','2022-01-01 00:00:00'), + ('Rename table dashboard to dashboard_v1 - v1','ALTER TABLE `dashboard` RENAME TO `dashboard_v1`',1,'','2022-01-01 00:00:00'), + ('create dashboard v2','CREATE TABLE IF NOT EXISTS `dashboard` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `version` INT NOT NULL\n, `slug` VARCHAR(189) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `title` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `data` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create index IDX_dashboard_org_id - v2','CREATE INDEX `IDX_dashboard_org_id` ON `dashboard` (`org_id`);',1,'','2022-01-01 00:00:00'), + ('create index UQE_dashboard_org_id_slug - v2','CREATE UNIQUE INDEX `UQE_dashboard_org_id_slug` ON `dashboard` (`org_id`,`slug`);',1,'','2022-01-01 00:00:00'), + ('copy dashboard v1 to v2','INSERT INTO `dashboard` (`id`\n, `version`\n, `slug`\n, `title`\n, `data`\n, `org_id`\n, `created`\n, `updated`)\nSELECT `id`\n, `version`\n, `slug`\n, `title`\n, `data`\n, `account_id`\n, `created`\n, `updated`\nFROM `dashboard_v1`',1,'','2022-01-01 00:00:00'), + ('drop table dashboard_v1','DROP TABLE IF EXISTS `dashboard_v1`',1,'','2022-01-01 00:00:00'), + ('alter dashboard.data to mediumtext v1','ALTER TABLE dashboard MODIFY data MEDIUMTEXT;',1,'','2022-01-01 00:00:00'), + ('Add column updated_by in dashboard - v2','alter table `dashboard` ADD COLUMN `updated_by` INT NULL ',1,'','2022-01-01 00:00:00'), + ('Add column created_by in dashboard - v2','alter table `dashboard` ADD COLUMN `created_by` INT NULL ',1,'','2022-01-01 00:00:00'), + ('Add column gnetId in dashboard','alter table `dashboard` ADD COLUMN `gnet_id` BIGINT(20) NULL ',1,'','2022-01-01 00:00:00'), + ('Add index for gnetId in dashboard','CREATE INDEX `IDX_dashboard_gnet_id` ON `dashboard` (`gnet_id`);',1,'','2022-01-01 00:00:00'), + ('Add column plugin_id in dashboard','alter table `dashboard` ADD COLUMN `plugin_id` VARCHAR(189) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Add index for plugin_id in dashboard','CREATE INDEX `IDX_dashboard_org_id_plugin_id` ON `dashboard` (`org_id`,`plugin_id`);',1,'','2022-01-01 00:00:00'), + ('Add index for dashboard_id in dashboard_tag','CREATE INDEX `IDX_dashboard_tag_dashboard_id` ON `dashboard_tag` (`dashboard_id`);',1,'','2022-01-01 00:00:00'), + ('Update dashboard table charset','ALTER TABLE `dashboard` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, MODIFY `slug` VARCHAR(189) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `title` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `plugin_id` VARCHAR(189) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL , MODIFY `data` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL ;',1,'','2022-01-01 00:00:00'), + ('Update dashboard_tag table charset','ALTER TABLE `dashboard_tag` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, MODIFY `term` VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL ;',1,'','2022-01-01 00:00:00'), + ('Add column folder_id in dashboard','alter table `dashboard` ADD COLUMN `folder_id` BIGINT(20) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('Add column isFolder in dashboard','alter table `dashboard` ADD COLUMN `is_folder` TINYINT(1) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('Add column has_acl in dashboard','alter table `dashboard` ADD COLUMN `has_acl` TINYINT(1) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('Add column uid in dashboard','alter table `dashboard` ADD COLUMN `uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Update uid column values in dashboard','UPDATE dashboard SET uid=lpad(id,9,\'0\') WHERE uid IS NULL;',1,'','2022-01-01 00:00:00'), + ('Add unique index dashboard_org_id_uid','CREATE UNIQUE INDEX `UQE_dashboard_org_id_uid` ON `dashboard` (`org_id`,`uid`);',1,'','2022-01-01 00:00:00'), + ('Remove unique index org_id_slug','DROP INDEX `UQE_dashboard_org_id_slug` ON `dashboard`',1,'','2022-01-01 00:00:00'), + ('Update dashboard title length','ALTER TABLE `dashboard` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, MODIFY `title` VARCHAR(189) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL ;',1,'','2022-01-01 00:00:00'), + ('Add unique index for dashboard_org_id_title_folder_id','CREATE UNIQUE INDEX `UQE_dashboard_org_id_folder_id_title` ON `dashboard` (`org_id`,`folder_id`,`title`);',1,'','2022-01-01 00:00:00'), + ('create dashboard_provisioning','CREATE TABLE IF NOT EXISTS `dashboard_provisioning` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `dashboard_id` BIGINT(20) NULL\n, `name` VARCHAR(150) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `external_id` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('Rename table dashboard_provisioning to dashboard_provisioning_tmp_qwerty - v1','ALTER TABLE `dashboard_provisioning` RENAME TO `dashboard_provisioning_tmp_qwerty`',1,'','2022-01-01 00:00:00'), + ('create dashboard_provisioning v2','CREATE TABLE IF NOT EXISTS `dashboard_provisioning` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `dashboard_id` BIGINT(20) NULL\n, `name` VARCHAR(150) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `external_id` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `updated` INT NOT NULL DEFAULT 0\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create index IDX_dashboard_provisioning_dashboard_id - v2','CREATE INDEX `IDX_dashboard_provisioning_dashboard_id` ON `dashboard_provisioning` (`dashboard_id`);',1,'','2022-01-01 00:00:00'), + ('create index IDX_dashboard_provisioning_dashboard_id_name - v2','CREATE INDEX `IDX_dashboard_provisioning_dashboard_id_name` ON `dashboard_provisioning` (`dashboard_id`,`name`);',1,'','2022-01-01 00:00:00'), + ('copy dashboard_provisioning v1 to v2','INSERT INTO `dashboard_provisioning` (`name`\n, `external_id`\n, `id`\n, `dashboard_id`)\nSELECT `name`\n, `external_id`\n, `id`\n, `dashboard_id`\nFROM `dashboard_provisioning_tmp_qwerty`',1,'','2022-01-01 00:00:00'), + ('drop dashboard_provisioning_tmp_qwerty','DROP TABLE IF EXISTS `dashboard_provisioning_tmp_qwerty`',1,'','2022-01-01 00:00:00'), + ('Add check_sum column','alter table `dashboard_provisioning` ADD COLUMN `check_sum` VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Add index for dashboard_title','CREATE INDEX `IDX_dashboard_title` ON `dashboard` (`title`);',1,'','2022-01-01 00:00:00'), + ('delete tags for deleted dashboards','DELETE FROM dashboard_tag WHERE dashboard_id NOT IN (SELECT id FROM dashboard)',1,'','2022-01-01 00:00:00'), + ('delete stars for deleted dashboards','DELETE FROM star WHERE dashboard_id NOT IN (SELECT id FROM dashboard)',1,'','2022-01-01 00:00:00'), + ('Add index for dashboard_is_folder','CREATE INDEX `IDX_dashboard_is_folder` ON `dashboard` (`is_folder`);',1,'','2022-01-01 00:00:00'), + ('Add isPublic for dashboard','alter table `dashboard` ADD COLUMN `is_public` TINYINT(1) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('Add deleted for dashboard','alter table `dashboard` ADD COLUMN `deleted` DATETIME NULL ',1,'','2022-01-01 00:00:00'), + ('Add index for deleted','CREATE INDEX `IDX_dashboard_deleted` ON `dashboard` (`deleted`);',1,'','2022-01-01 00:00:00'), + ('Add column dashboard_uid in dashboard_tag','alter table `dashboard_tag` ADD COLUMN `dashboard_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Add column org_id in dashboard_tag','alter table `dashboard_tag` ADD COLUMN `org_id` BIGINT(20) NULL DEFAULT 1 ',1,'','2022-01-01 00:00:00'), + ('Add missing dashboard_uid and org_id to dashboard_tag','code migration',1,'','2022-01-01 00:00:00'), + ('Add apiVersion for dashboard','alter table `dashboard` ADD COLUMN `api_version` VARCHAR(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Add index for dashboard_uid on dashboard_tag table','CREATE INDEX `IDX_dashboard_tag_dashboard_uid` ON `dashboard_tag` (`dashboard_uid`);',1,'','2022-01-01 00:00:00'), + ('Add missing dashboard_uid and org_id to star','code migration',1,'','2022-01-01 00:00:00'), + ('create data_source table','CREATE TABLE IF NOT EXISTS `data_source` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `account_id` BIGINT(20) NOT NULL\n, `version` INT NOT NULL\n, `type` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `name` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `access` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `url` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `password` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `user` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `database` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `basic_auth` TINYINT(1) NOT NULL\n, `basic_auth_user` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `basic_auth_password` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `is_default` TINYINT(1) NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index data_source.account_id','CREATE INDEX `IDX_data_source_account_id` ON `data_source` (`account_id`);',1,'','2022-01-01 00:00:00'), + ('add unique index data_source.account_id_name','CREATE UNIQUE INDEX `UQE_data_source_account_id_name` ON `data_source` (`account_id`,`name`);',1,'','2022-01-01 00:00:00'), + ('drop index IDX_data_source_account_id - v1','DROP INDEX `IDX_data_source_account_id` ON `data_source`',1,'','2022-01-01 00:00:00'), + ('drop index UQE_data_source_account_id_name - v1','DROP INDEX `UQE_data_source_account_id_name` ON `data_source`',1,'','2022-01-01 00:00:00'), + ('Rename table data_source to data_source_v1 - v1','ALTER TABLE `data_source` RENAME TO `data_source_v1`',1,'','2022-01-01 00:00:00'), + ('create data_source table v2','CREATE TABLE IF NOT EXISTS `data_source` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `version` INT NOT NULL\n, `type` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `name` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `access` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `url` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `password` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `user` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `database` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `basic_auth` TINYINT(1) NOT NULL\n, `basic_auth_user` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `basic_auth_password` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `is_default` TINYINT(1) NOT NULL\n, `json_data` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create index IDX_data_source_org_id - v2','CREATE INDEX `IDX_data_source_org_id` ON `data_source` (`org_id`);',1,'','2022-01-01 00:00:00'), + ('create index UQE_data_source_org_id_name - v2','CREATE UNIQUE INDEX `UQE_data_source_org_id_name` ON `data_source` (`org_id`,`name`);',1,'','2022-01-01 00:00:00'), + ('Drop old table data_source_v1 #2','DROP TABLE IF EXISTS `data_source_v1`',1,'','2022-01-01 00:00:00'), + ('Add column with_credentials','alter table `data_source` ADD COLUMN `with_credentials` TINYINT(1) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('Add secure json data column','alter table `data_source` ADD COLUMN `secure_json_data` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Update data_source table charset','ALTER TABLE `data_source` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, MODIFY `type` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `name` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `access` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `url` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `password` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL , MODIFY `user` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL , MODIFY `database` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL , MODIFY `basic_auth_user` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL , MODIFY `basic_auth_password` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL , MODIFY `json_data` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL , MODIFY `secure_json_data` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ;',1,'','2022-01-01 00:00:00'), + ('Update initial version to 1','UPDATE data_source SET version = 1 WHERE version = 0',1,'','2022-01-01 00:00:00'), + ('Add read_only data column','alter table `data_source` ADD COLUMN `read_only` TINYINT(1) NULL ',1,'','2022-01-01 00:00:00'), + ('Migrate logging ds to loki ds','UPDATE data_source SET type = \'loki\' WHERE type = \'logging\'',1,'','2022-01-01 00:00:00'), + ('Update json_data with nulls','UPDATE data_source SET json_data = \'{}\' WHERE json_data is null',1,'','2022-01-01 00:00:00'), + ('Add uid column','alter table `data_source` ADD COLUMN `uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('Update uid value','UPDATE data_source SET uid=lpad(id,9,\'0\');',1,'','2022-01-01 00:00:00'), + ('Add unique index datasource_org_id_uid','CREATE UNIQUE INDEX `UQE_data_source_org_id_uid` ON `data_source` (`org_id`,`uid`);',1,'','2022-01-01 00:00:00'), + ('add unique index datasource_org_id_is_default','CREATE INDEX `IDX_data_source_org_id_is_default` ON `data_source` (`org_id`,`is_default`);',1,'','2022-01-01 00:00:00'), + ('Add is_prunable column','alter table `data_source` ADD COLUMN `is_prunable` TINYINT(1) NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('Add api_version column','alter table `data_source` ADD COLUMN `api_version` VARCHAR(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Update secure_json_data column to MediumText','ALTER TABLE data_source MODIFY COLUMN secure_json_data MEDIUMTEXT;',1,'','2022-01-01 00:00:00'), + ('Update json_data column to MediumText','ALTER TABLE data_source MODIFY COLUMN json_data MEDIUMTEXT;',1,'','2022-01-01 00:00:00'), + ('create api_key table','CREATE TABLE IF NOT EXISTS `api_key` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `account_id` BIGINT(20) NOT NULL\n, `name` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `key` VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `role` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index api_key.account_id','CREATE INDEX `IDX_api_key_account_id` ON `api_key` (`account_id`);',1,'','2022-01-01 00:00:00'), + ('add index api_key.key','CREATE UNIQUE INDEX `UQE_api_key_key` ON `api_key` (`key`);',1,'','2022-01-01 00:00:00'), + ('add index api_key.account_id_name','CREATE UNIQUE INDEX `UQE_api_key_account_id_name` ON `api_key` (`account_id`,`name`);',1,'','2022-01-01 00:00:00'), + ('drop index IDX_api_key_account_id - v1','DROP INDEX `IDX_api_key_account_id` ON `api_key`',1,'','2022-01-01 00:00:00'), + ('drop index UQE_api_key_key - v1','DROP INDEX `UQE_api_key_key` ON `api_key`',1,'','2022-01-01 00:00:00'), + ('drop index UQE_api_key_account_id_name - v1','DROP INDEX `UQE_api_key_account_id_name` ON `api_key`',1,'','2022-01-01 00:00:00'), + ('Rename table api_key to api_key_v1 - v1','ALTER TABLE `api_key` RENAME TO `api_key_v1`',1,'','2022-01-01 00:00:00'), + ('create api_key table v2','CREATE TABLE IF NOT EXISTS `api_key` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `name` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `key` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `role` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create index IDX_api_key_org_id - v2','CREATE INDEX `IDX_api_key_org_id` ON `api_key` (`org_id`);',1,'','2022-01-01 00:00:00'), + ('create index UQE_api_key_key - v2','CREATE UNIQUE INDEX `UQE_api_key_key` ON `api_key` (`key`);',1,'','2022-01-01 00:00:00'), + ('create index UQE_api_key_org_id_name - v2','CREATE UNIQUE INDEX `UQE_api_key_org_id_name` ON `api_key` (`org_id`,`name`);',1,'','2022-01-01 00:00:00'), + ('copy api_key v1 to v2','INSERT INTO `api_key` (`name`\n, `key`\n, `role`\n, `created`\n, `updated`\n, `id`\n, `org_id`)\nSELECT `name`\n, `key`\n, `role`\n, `created`\n, `updated`\n, `id`\n, `account_id`\nFROM `api_key_v1`',1,'','2022-01-01 00:00:00'), + ('Drop old table api_key_v1','DROP TABLE IF EXISTS `api_key_v1`',1,'','2022-01-01 00:00:00'), + ('Update api_key table charset','ALTER TABLE `api_key` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, MODIFY `name` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `key` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `role` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL ;',1,'','2022-01-01 00:00:00'), + ('Add expires to api_key table','alter table `api_key` ADD COLUMN `expires` BIGINT(20) NULL ',1,'','2022-01-01 00:00:00'), + ('Add service account foreign key','alter table `api_key` ADD COLUMN `service_account_id` BIGINT(20) NULL ',1,'','2022-01-01 00:00:00'), + ('set service account foreign key to nil if 0','UPDATE api_key SET service_account_id = NULL WHERE service_account_id = 0;',1,'','2022-01-01 00:00:00'), + ('Add last_used_at to api_key table','alter table `api_key` ADD COLUMN `last_used_at` DATETIME NULL ',1,'','2022-01-01 00:00:00'), + ('Add is_revoked column to api_key table','alter table `api_key` ADD COLUMN `is_revoked` TINYINT(1) NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('create dashboard_snapshot table v4','CREATE TABLE IF NOT EXISTS `dashboard_snapshot` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `name` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `key` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `dashboard` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `expires` DATETIME NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('drop table dashboard_snapshot_v4 #1','DROP TABLE IF EXISTS `dashboard_snapshot`',1,'','2022-01-01 00:00:00'), + ('create dashboard_snapshot table v5 #2','CREATE TABLE IF NOT EXISTS `dashboard_snapshot` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `name` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `key` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `delete_key` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `user_id` BIGINT(20) NOT NULL\n, `external` TINYINT(1) NOT NULL\n, `external_url` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `dashboard` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `expires` DATETIME NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create index UQE_dashboard_snapshot_key - v5','CREATE UNIQUE INDEX `UQE_dashboard_snapshot_key` ON `dashboard_snapshot` (`key`);',1,'','2022-01-01 00:00:00'), + ('create index UQE_dashboard_snapshot_delete_key - v5','CREATE UNIQUE INDEX `UQE_dashboard_snapshot_delete_key` ON `dashboard_snapshot` (`delete_key`);',1,'','2022-01-01 00:00:00'), + ('create index IDX_dashboard_snapshot_user_id - v5','CREATE INDEX `IDX_dashboard_snapshot_user_id` ON `dashboard_snapshot` (`user_id`);',1,'','2022-01-01 00:00:00'), + ('alter dashboard_snapshot to mediumtext v2','ALTER TABLE dashboard_snapshot MODIFY dashboard MEDIUMTEXT;',1,'','2022-01-01 00:00:00'), + ('Update dashboard_snapshot table charset','ALTER TABLE `dashboard_snapshot` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, MODIFY `name` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `key` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `delete_key` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `external_url` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `dashboard` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL ;',1,'','2022-01-01 00:00:00'), + ('Add column external_delete_url to dashboard_snapshots table','alter table `dashboard_snapshot` ADD COLUMN `external_delete_url` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Add encrypted dashboard json column','alter table `dashboard_snapshot` ADD COLUMN `dashboard_encrypted` BLOB NULL ',1,'','2022-01-01 00:00:00'), + ('Change dashboard_encrypted column to MEDIUMBLOB','ALTER TABLE dashboard_snapshot MODIFY dashboard_encrypted MEDIUMBLOB;',1,'','2022-01-01 00:00:00'), + ('create quota table v1','CREATE TABLE IF NOT EXISTS `quota` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `org_id` BIGINT(20) NULL\n, `user_id` BIGINT(20) NULL\n, `target` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `limit` BIGINT(20) NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create index UQE_quota_org_id_user_id_target - v1','CREATE UNIQUE INDEX `UQE_quota_org_id_user_id_target` ON `quota` (`org_id`,`user_id`,`target`);',1,'','2022-01-01 00:00:00'), + ('Update quota table charset','ALTER TABLE `quota` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, MODIFY `target` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL ;',1,'','2022-01-01 00:00:00'), + ('create plugin_setting table','CREATE TABLE IF NOT EXISTS `plugin_setting` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `org_id` BIGINT(20) NULL\n, `plugin_id` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `enabled` TINYINT(1) NOT NULL\n, `pinned` TINYINT(1) NOT NULL\n, `json_data` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `secure_json_data` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create index UQE_plugin_setting_org_id_plugin_id - v1','CREATE UNIQUE INDEX `UQE_plugin_setting_org_id_plugin_id` ON `plugin_setting` (`org_id`,`plugin_id`);',1,'','2022-01-01 00:00:00'), + ('Add column plugin_version to plugin_settings','alter table `plugin_setting` ADD COLUMN `plugin_version` VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Update plugin_setting table charset','ALTER TABLE `plugin_setting` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, MODIFY `plugin_id` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `json_data` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL , MODIFY `secure_json_data` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL , MODIFY `plugin_version` VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ;',1,'','2022-01-01 00:00:00'), + ('update NULL org_id to 1','UPDATE plugin_setting SET org_id=1 where org_id IS NULL;',1,'','2022-01-01 00:00:00'), + ('make org_id NOT NULL and DEFAULT VALUE 1','ALTER TABLE plugin_setting MODIFY COLUMN org_id BIGINT NOT NULL DEFAULT 1;',1,'','2022-01-01 00:00:00'), + ('create session table','CREATE TABLE IF NOT EXISTS `session` (\n`key` CHAR(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci PRIMARY KEY NOT NULL\n, `data` BLOB NOT NULL\n, `expiry` INTEGER(255) NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('Drop old table playlist table','DROP TABLE IF EXISTS `playlist`',1,'','2022-01-01 00:00:00'), + ('Drop old table playlist_item table','DROP TABLE IF EXISTS `playlist_item`',1,'','2022-01-01 00:00:00'), + ('create playlist table v2','CREATE TABLE IF NOT EXISTS `playlist` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `name` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `interval` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create playlist item table v2','CREATE TABLE IF NOT EXISTS `playlist_item` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `playlist_id` BIGINT(20) NOT NULL\n, `type` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `value` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `title` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `order` INT NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('Update playlist table charset','ALTER TABLE `playlist` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, MODIFY `name` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `interval` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL ;',1,'','2022-01-01 00:00:00'), + ('Update playlist_item table charset','ALTER TABLE `playlist_item` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, MODIFY `type` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `value` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `title` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL ;',1,'','2022-01-01 00:00:00'), + ('Add playlist column created_at','alter table `playlist` ADD COLUMN `created_at` BIGINT(20) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('Add playlist column updated_at','alter table `playlist` ADD COLUMN `updated_at` BIGINT(20) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('drop preferences table v2','DROP TABLE IF EXISTS `preferences`',1,'','2022-01-01 00:00:00'), + ('drop preferences table v3','DROP TABLE IF EXISTS `preferences`',1,'','2022-01-01 00:00:00'), + ('create preferences table v3','CREATE TABLE IF NOT EXISTS `preferences` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `user_id` BIGINT(20) NOT NULL\n, `version` INT NOT NULL\n, `home_dashboard_id` BIGINT(20) NOT NULL\n, `timezone` VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `theme` VARCHAR(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('Update preferences table charset','ALTER TABLE `preferences` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, MODIFY `timezone` VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `theme` VARCHAR(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL ;',1,'','2022-01-01 00:00:00'), + ('Add column team_id in preferences','alter table `preferences` ADD COLUMN `team_id` BIGINT(20) NULL ',1,'','2022-01-01 00:00:00'), + ('Update team_id column values in preferences','UPDATE preferences SET team_id=0 WHERE team_id IS NULL;',1,'','2022-01-01 00:00:00'), + ('Add column week_start in preferences','alter table `preferences` ADD COLUMN `week_start` VARCHAR(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Add column preferences.json_data','alter table `preferences` ADD COLUMN `json_data` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('alter preferences.json_data to mediumtext v1','ALTER TABLE preferences MODIFY json_data MEDIUMTEXT;',1,'','2022-01-01 00:00:00'), + ('Add preferences index org_id','CREATE INDEX `IDX_preferences_org_id` ON `preferences` (`org_id`);',1,'','2022-01-01 00:00:00'), + ('Add preferences index user_id','CREATE INDEX `IDX_preferences_user_id` ON `preferences` (`user_id`);',1,'','2022-01-01 00:00:00'), + ('Add home_dashboard_uid column to preferences table','alter table `preferences` ADD COLUMN `home_dashboard_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Add missing dashboard_uid to preferences table','code migration',1,'','2022-01-01 00:00:00'), + ('create alert table v1','CREATE TABLE IF NOT EXISTS `alert` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `version` BIGINT(20) NOT NULL\n, `dashboard_id` BIGINT(20) NOT NULL\n, `panel_id` BIGINT(20) NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `name` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `message` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `state` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `settings` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `frequency` BIGINT(20) NOT NULL\n, `handler` BIGINT(20) NOT NULL\n, `severity` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `silenced` TINYINT(1) NOT NULL\n, `execution_error` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `eval_data` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `eval_date` DATETIME NULL\n, `new_state_date` DATETIME NOT NULL\n, `state_changes` INT NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index alert org_id & id ','CREATE INDEX `IDX_alert_org_id_id` ON `alert` (`org_id`,`id`);',1,'','2022-01-01 00:00:00'), + ('add index alert state','CREATE INDEX `IDX_alert_state` ON `alert` (`state`);',1,'','2022-01-01 00:00:00'), + ('add index alert dashboard_id','CREATE INDEX `IDX_alert_dashboard_id` ON `alert` (`dashboard_id`);',1,'','2022-01-01 00:00:00'), + ('Create alert_rule_tag table v1','CREATE TABLE IF NOT EXISTS `alert_rule_tag` (\n`alert_id` BIGINT(20) NOT NULL\n, `tag_id` BIGINT(20) NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('Add unique index alert_rule_tag.alert_id_tag_id','CREATE UNIQUE INDEX `UQE_alert_rule_tag_alert_id_tag_id` ON `alert_rule_tag` (`alert_id`,`tag_id`);',1,'','2022-01-01 00:00:00'), + ('drop index UQE_alert_rule_tag_alert_id_tag_id - v1','DROP INDEX `UQE_alert_rule_tag_alert_id_tag_id` ON `alert_rule_tag`',1,'','2022-01-01 00:00:00'), + ('Rename table alert_rule_tag to alert_rule_tag_v1 - v1','ALTER TABLE `alert_rule_tag` RENAME TO `alert_rule_tag_v1`',1,'','2022-01-01 00:00:00'), + ('Create alert_rule_tag table v2','CREATE TABLE IF NOT EXISTS `alert_rule_tag` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `alert_id` BIGINT(20) NOT NULL\n, `tag_id` BIGINT(20) NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create index UQE_alert_rule_tag_alert_id_tag_id - Add unique index alert_rule_tag.alert_id_tag_id V2','CREATE UNIQUE INDEX `UQE_alert_rule_tag_alert_id_tag_id` ON `alert_rule_tag` (`alert_id`,`tag_id`);',1,'','2022-01-01 00:00:00'), + ('copy alert_rule_tag v1 to v2','INSERT INTO `alert_rule_tag` (`alert_id`\n, `tag_id`)\nSELECT `alert_id`\n, `tag_id`\nFROM `alert_rule_tag_v1`',1,'','2022-01-01 00:00:00'), + ('drop table alert_rule_tag_v1','DROP TABLE IF EXISTS `alert_rule_tag_v1`',1,'','2022-01-01 00:00:00'), + ('create alert_notification table v1','CREATE TABLE IF NOT EXISTS `alert_notification` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `name` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `type` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `settings` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('Add column is_default','alter table `alert_notification` ADD COLUMN `is_default` TINYINT(1) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('Add column frequency','alter table `alert_notification` ADD COLUMN `frequency` BIGINT(20) NULL ',1,'','2022-01-01 00:00:00'), + ('Add column send_reminder','alter table `alert_notification` ADD COLUMN `send_reminder` TINYINT(1) NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('Add column disable_resolve_message','alter table `alert_notification` ADD COLUMN `disable_resolve_message` TINYINT(1) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('add index alert_notification org_id & name','CREATE UNIQUE INDEX `UQE_alert_notification_org_id_name` ON `alert_notification` (`org_id`,`name`);',1,'','2022-01-01 00:00:00'), + ('Update alert table charset','ALTER TABLE `alert` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, MODIFY `name` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `message` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `state` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `settings` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `severity` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `execution_error` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `eval_data` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ;',1,'','2022-01-01 00:00:00'), + ('Update alert_notification table charset','ALTER TABLE `alert_notification` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, MODIFY `name` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `type` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `settings` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL ;',1,'','2022-01-01 00:00:00'), + ('create notification_journal table v1','CREATE TABLE IF NOT EXISTS `alert_notification_journal` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `alert_id` BIGINT(20) NOT NULL\n, `notifier_id` BIGINT(20) NOT NULL\n, `sent_at` BIGINT(20) NOT NULL\n, `success` TINYINT(1) NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index notification_journal org_id & alert_id & notifier_id','CREATE INDEX `IDX_alert_notification_journal_org_id_alert_id_notifier_id` ON `alert_notification_journal` (`org_id`,`alert_id`,`notifier_id`);',1,'','2022-01-01 00:00:00'), + ('drop alert_notification_journal','DROP TABLE IF EXISTS `alert_notification_journal`',1,'','2022-01-01 00:00:00'), + ('create alert_notification_state table v1','CREATE TABLE IF NOT EXISTS `alert_notification_state` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `alert_id` BIGINT(20) NOT NULL\n, `notifier_id` BIGINT(20) NOT NULL\n, `state` VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `version` BIGINT(20) NOT NULL\n, `updated_at` BIGINT(20) NOT NULL\n, `alert_rule_state_updated_version` BIGINT(20) NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index alert_notification_state org_id & alert_id & notifier_id','CREATE UNIQUE INDEX `UQE_alert_notification_state_org_id_alert_id_notifier_id` ON `alert_notification_state` (`org_id`,`alert_id`,`notifier_id`);',1,'','2022-01-01 00:00:00'), + ('Add for to alert table','alter table `alert` ADD COLUMN `for` BIGINT(20) NULL ',1,'','2022-01-01 00:00:00'), + ('Add column uid in alert_notification','alter table `alert_notification` ADD COLUMN `uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Update uid column values in alert_notification','UPDATE alert_notification SET uid=lpad(id,9,\'0\') WHERE uid IS NULL;',1,'','2022-01-01 00:00:00'), + ('Add unique index alert_notification_org_id_uid','CREATE UNIQUE INDEX `UQE_alert_notification_org_id_uid` ON `alert_notification` (`org_id`,`uid`);',1,'','2022-01-01 00:00:00'), + ('Remove unique index org_id_name','DROP INDEX `UQE_alert_notification_org_id_name` ON `alert_notification`',1,'','2022-01-01 00:00:00'), + ('Add column secure_settings in alert_notification','alter table `alert_notification` ADD COLUMN `secure_settings` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('alter alert.settings to mediumtext','ALTER TABLE alert MODIFY settings MEDIUMTEXT;',1,'','2022-01-01 00:00:00'), + ('Add non-unique index alert_notification_state_alert_id','CREATE INDEX `IDX_alert_notification_state_alert_id` ON `alert_notification_state` (`alert_id`);',1,'','2022-01-01 00:00:00'), + ('Add non-unique index alert_rule_tag_alert_id','CREATE INDEX `IDX_alert_rule_tag_alert_id` ON `alert_rule_tag` (`alert_id`);',1,'','2022-01-01 00:00:00'), + ('Drop old annotation table v4','DROP TABLE IF EXISTS `annotation`',1,'','2022-01-01 00:00:00'), + ('create annotation table v5','CREATE TABLE IF NOT EXISTS `annotation` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `alert_id` BIGINT(20) NULL\n, `user_id` BIGINT(20) NULL\n, `dashboard_id` BIGINT(20) NULL\n, `panel_id` BIGINT(20) NULL\n, `category_id` BIGINT(20) NULL\n, `type` VARCHAR(25) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `title` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `text` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `metric` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `prev_state` VARCHAR(25) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `new_state` VARCHAR(25) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `data` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `epoch` BIGINT(20) NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index annotation 0 v3','CREATE INDEX `IDX_annotation_org_id_alert_id` ON `annotation` (`org_id`,`alert_id`);',1,'','2022-01-01 00:00:00'), + ('add index annotation 1 v3','CREATE INDEX `IDX_annotation_org_id_type` ON `annotation` (`org_id`,`type`);',1,'','2022-01-01 00:00:00'), + ('add index annotation 2 v3','CREATE INDEX `IDX_annotation_org_id_category_id` ON `annotation` (`org_id`,`category_id`);',1,'','2022-01-01 00:00:00'), + ('add index annotation 3 v3','CREATE INDEX `IDX_annotation_org_id_dashboard_id_panel_id_epoch` ON `annotation` (`org_id`,`dashboard_id`,`panel_id`,`epoch`);',1,'','2022-01-01 00:00:00'), + ('add index annotation 4 v3','CREATE INDEX `IDX_annotation_org_id_epoch` ON `annotation` (`org_id`,`epoch`);',1,'','2022-01-01 00:00:00'), + ('Update annotation table charset','ALTER TABLE `annotation` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, MODIFY `type` VARCHAR(25) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `title` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `text` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `metric` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL , MODIFY `prev_state` VARCHAR(25) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `new_state` VARCHAR(25) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL , MODIFY `data` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL ;',1,'','2022-01-01 00:00:00'), + ('Add column region_id to annotation table','alter table `annotation` ADD COLUMN `region_id` BIGINT(20) NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('Drop category_id index','DROP INDEX `IDX_annotation_org_id_category_id` ON `annotation`',1,'','2022-01-01 00:00:00'), + ('Add column tags to annotation table','alter table `annotation` ADD COLUMN `tags` VARCHAR(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Create annotation_tag table v2','CREATE TABLE IF NOT EXISTS `annotation_tag` (\n`annotation_id` BIGINT(20) NOT NULL\n, `tag_id` BIGINT(20) NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('Add unique index annotation_tag.annotation_id_tag_id','CREATE UNIQUE INDEX `UQE_annotation_tag_annotation_id_tag_id` ON `annotation_tag` (`annotation_id`,`tag_id`);',1,'','2022-01-01 00:00:00'), + ('drop index UQE_annotation_tag_annotation_id_tag_id - v2','DROP INDEX `UQE_annotation_tag_annotation_id_tag_id` ON `annotation_tag`',1,'','2022-01-01 00:00:00'), + ('Rename table annotation_tag to annotation_tag_v2 - v2','ALTER TABLE `annotation_tag` RENAME TO `annotation_tag_v2`',1,'','2022-01-01 00:00:00'), + ('Create annotation_tag table v3','CREATE TABLE IF NOT EXISTS `annotation_tag` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `annotation_id` BIGINT(20) NOT NULL\n, `tag_id` BIGINT(20) NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create index UQE_annotation_tag_annotation_id_tag_id - Add unique index annotation_tag.annotation_id_tag_id V3','CREATE UNIQUE INDEX `UQE_annotation_tag_annotation_id_tag_id` ON `annotation_tag` (`annotation_id`,`tag_id`);',1,'','2022-01-01 00:00:00'), + ('copy annotation_tag v2 to v3','INSERT INTO `annotation_tag` (`annotation_id`\n, `tag_id`)\nSELECT `annotation_id`\n, `tag_id`\nFROM `annotation_tag_v2`',1,'','2022-01-01 00:00:00'), + ('drop table annotation_tag_v2','DROP TABLE IF EXISTS `annotation_tag_v2`',1,'','2022-01-01 00:00:00'), + ('Update alert annotations and set TEXT to empty','UPDATE annotation SET TEXT = \'\' WHERE alert_id > 0',1,'','2022-01-01 00:00:00'), + ('Add created time to annotation table','alter table `annotation` ADD COLUMN `created` BIGINT(20) NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('Add updated time to annotation table','alter table `annotation` ADD COLUMN `updated` BIGINT(20) NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('Add index for created in annotation table','CREATE INDEX `IDX_annotation_org_id_created` ON `annotation` (`org_id`,`created`);',1,'','2022-01-01 00:00:00'), + ('Add index for updated in annotation table','CREATE INDEX `IDX_annotation_org_id_updated` ON `annotation` (`org_id`,`updated`);',1,'','2022-01-01 00:00:00'), + ('Convert existing annotations from seconds to milliseconds','UPDATE annotation SET epoch = (epoch*1000) where epoch < 9999999999',1,'','2022-01-01 00:00:00'), + ('Add epoch_end column','alter table `annotation` ADD COLUMN `epoch_end` BIGINT(20) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('Add index for epoch_end','CREATE INDEX `IDX_annotation_org_id_epoch_epoch_end` ON `annotation` (`org_id`,`epoch`,`epoch_end`);',1,'','2022-01-01 00:00:00'), + ('Make epoch_end the same as epoch','UPDATE annotation SET epoch_end = epoch',1,'','2022-01-01 00:00:00'), + ('Move region to single row','code migration',1,'','2022-01-01 00:00:00'), + ('Remove index org_id_epoch from annotation table','DROP INDEX `IDX_annotation_org_id_epoch` ON `annotation`',1,'','2022-01-01 00:00:00'), + ('Remove index org_id_dashboard_id_panel_id_epoch from annotation table','DROP INDEX `IDX_annotation_org_id_dashboard_id_panel_id_epoch` ON `annotation`',1,'','2022-01-01 00:00:00'), + ('Add index for org_id_dashboard_id_epoch_end_epoch on annotation table','CREATE INDEX `IDX_annotation_org_id_dashboard_id_epoch_end_epoch` ON `annotation` (`org_id`,`dashboard_id`,`epoch_end`,`epoch`);',1,'','2022-01-01 00:00:00'), + ('Add index for org_id_epoch_end_epoch on annotation table','CREATE INDEX `IDX_annotation_org_id_epoch_end_epoch` ON `annotation` (`org_id`,`epoch_end`,`epoch`);',1,'','2022-01-01 00:00:00'), + ('Remove index org_id_epoch_epoch_end from annotation table','DROP INDEX `IDX_annotation_org_id_epoch_epoch_end` ON `annotation`',1,'','2022-01-01 00:00:00'), + ('Add index for alert_id on annotation table','CREATE INDEX `IDX_annotation_alert_id` ON `annotation` (`alert_id`);',1,'','2022-01-01 00:00:00'), + ('Increase tags column to length 4096','ALTER TABLE annotation MODIFY tags VARCHAR(4096);',1,'','2022-01-01 00:00:00'), + ('Increase prev_state column to length 40 not null','ALTER TABLE annotation MODIFY prev_state VARCHAR(40) NOT NULL;',1,'','2022-01-01 00:00:00'), + ('Increase new_state column to length 40 not null','ALTER TABLE annotation MODIFY new_state VARCHAR(40) NOT NULL;',1,'','2022-01-01 00:00:00'), + ('Add dashboard_uid column to annotation table','alter table `annotation` ADD COLUMN `dashboard_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Add missing dashboard_uid to annotation table','code migration',1,'','2022-01-01 00:00:00'), + ('create test_data table','CREATE TABLE IF NOT EXISTS `test_data` (\n`id` INT PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `metric1` VARCHAR(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `metric2` VARCHAR(150) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `value_big_int` BIGINT(20) NULL\n, `value_double` DOUBLE NULL\n, `value_float` FLOAT NULL\n, `value_int` INT NULL\n, `time_epoch` BIGINT(20) NOT NULL\n, `time_date_time` DATETIME NOT NULL\n, `time_time_stamp` TIMESTAMP NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create dashboard_version table v1','CREATE TABLE IF NOT EXISTS `dashboard_version` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `dashboard_id` BIGINT(20) NOT NULL\n, `parent_version` INT NOT NULL\n, `restored_from` INT NOT NULL\n, `version` INT NOT NULL\n, `created` DATETIME NOT NULL\n, `created_by` BIGINT(20) NOT NULL\n, `message` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `data` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index dashboard_version.dashboard_id','CREATE INDEX `IDX_dashboard_version_dashboard_id` ON `dashboard_version` (`dashboard_id`);',1,'','2022-01-01 00:00:00'), + ('add unique index dashboard_version.dashboard_id and dashboard_version.version','CREATE UNIQUE INDEX `UQE_dashboard_version_dashboard_id_version` ON `dashboard_version` (`dashboard_id`,`version`);',1,'','2022-01-01 00:00:00'), + ('Set dashboard version to 1 where 0','UPDATE dashboard SET version = 1 WHERE version = 0',1,'','2022-01-01 00:00:00'), + ('save existing dashboard data in dashboard_version table v1','INSERT INTO dashboard_version\n(\n dashboard_id,\n version,\n parent_version,\n restored_from,\n created,\n created_by,\n message,\n data\n)\nSELECT\n dashboard.id,\n dashboard.version,\n dashboard.version,\n dashboard.version,\n dashboard.updated,\n COALESCE(dashboard.updated_by, -1),\n \'\',\n dashboard.data\nFROM dashboard;',1,'','2022-01-01 00:00:00'), + ('alter dashboard_version.data to mediumtext v1','ALTER TABLE dashboard_version MODIFY data MEDIUMTEXT;',1,'','2022-01-01 00:00:00'), + ('Add apiVersion for dashboard_version','alter table `dashboard_version` ADD COLUMN `api_version` VARCHAR(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('create team table','CREATE TABLE IF NOT EXISTS `team` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `name` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index team.org_id','CREATE INDEX `IDX_team_org_id` ON `team` (`org_id`);',1,'','2022-01-01 00:00:00'), + ('add unique index team_org_id_name','CREATE UNIQUE INDEX `UQE_team_org_id_name` ON `team` (`org_id`,`name`);',1,'','2022-01-01 00:00:00'), + ('Add column uid in team','alter table `team` ADD COLUMN `uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Update uid column values in team','UPDATE team SET uid=concat(\'t\',lpad(id,9,\'0\')) WHERE uid IS NULL;',1,'','2022-01-01 00:00:00'), + ('Add unique index team_org_id_uid','CREATE UNIQUE INDEX `UQE_team_org_id_uid` ON `team` (`org_id`,`uid`);',1,'','2022-01-01 00:00:00'), + ('Add column external_uid in team','alter table `team` ADD COLUMN `external_uid` VARCHAR(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Add column is_provisioned in team','alter table `team` ADD COLUMN `is_provisioned` TINYINT(1) NULL ',1,'','2022-01-01 00:00:00'), + ('create team member table','CREATE TABLE IF NOT EXISTS `team_member` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `team_id` BIGINT(20) NOT NULL\n, `user_id` BIGINT(20) NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index team_member.org_id','CREATE INDEX `IDX_team_member_org_id` ON `team_member` (`org_id`);',1,'','2022-01-01 00:00:00'), + ('add unique index team_member_org_id_team_id_user_id','CREATE UNIQUE INDEX `UQE_team_member_org_id_team_id_user_id` ON `team_member` (`org_id`,`team_id`,`user_id`);',1,'','2022-01-01 00:00:00'), + ('add index team_member.team_id','CREATE INDEX `IDX_team_member_team_id` ON `team_member` (`team_id`);',1,'','2022-01-01 00:00:00'), + ('Add column email to team table','alter table `team` ADD COLUMN `email` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Add column external to team_member table','alter table `team_member` ADD COLUMN `external` TINYINT(1) NULL ',1,'','2022-01-01 00:00:00'), + ('Add column permission to team_member table','alter table `team_member` ADD COLUMN `permission` SMALLINT NULL ',1,'','2022-01-01 00:00:00'), + ('add unique index team_member_user_id_org_id','CREATE INDEX `IDX_team_member_user_id_org_id` ON `team_member` (`user_id`,`org_id`);',1,'','2022-01-01 00:00:00'), + ('Add column uid in team_member','alter table `team_member` ADD COLUMN `uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Update uid column values in team_member','UPDATE team_member SET uid=concat(\'tm\',lpad(id,9,\'0\')) WHERE uid IS NULL OR uid = \'\';',1,'','2022-01-01 00:00:00'), + ('Add unique index team_member_uid','CREATE UNIQUE INDEX `UQE_team_member_uid` ON `team_member` (`uid`);',1,'','2022-01-01 00:00:00'), + ('create dashboard acl table','CREATE TABLE IF NOT EXISTS `dashboard_acl` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `dashboard_id` BIGINT(20) NOT NULL\n, `user_id` BIGINT(20) NULL\n, `team_id` BIGINT(20) NULL\n, `permission` SMALLINT NOT NULL DEFAULT 4\n, `role` VARCHAR(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index dashboard_acl_dashboard_id','CREATE INDEX `IDX_dashboard_acl_dashboard_id` ON `dashboard_acl` (`dashboard_id`);',1,'','2022-01-01 00:00:00'), + ('add unique index dashboard_acl_dashboard_id_user_id','CREATE UNIQUE INDEX `UQE_dashboard_acl_dashboard_id_user_id` ON `dashboard_acl` (`dashboard_id`,`user_id`);',1,'','2022-01-01 00:00:00'), + ('add unique index dashboard_acl_dashboard_id_team_id','CREATE UNIQUE INDEX `UQE_dashboard_acl_dashboard_id_team_id` ON `dashboard_acl` (`dashboard_id`,`team_id`);',1,'','2022-01-01 00:00:00'), + ('add index dashboard_acl_user_id','CREATE INDEX `IDX_dashboard_acl_user_id` ON `dashboard_acl` (`user_id`);',1,'','2022-01-01 00:00:00'), + ('add index dashboard_acl_team_id','CREATE INDEX `IDX_dashboard_acl_team_id` ON `dashboard_acl` (`team_id`);',1,'','2022-01-01 00:00:00'), + ('add index dashboard_acl_org_id_role','CREATE INDEX `IDX_dashboard_acl_org_id_role` ON `dashboard_acl` (`org_id`,`role`);',1,'','2022-01-01 00:00:00'), + ('add index dashboard_permission','CREATE INDEX `IDX_dashboard_acl_permission` ON `dashboard_acl` (`permission`);',1,'','2022-01-01 00:00:00'), + ('save default acl rules in dashboard_acl table','\nINSERT INTO dashboard_acl\n (\n org_id,\n dashboard_id,\n permission,\n role,\n created,\n updated\n )\n VALUES\n (-1,-1, 1,\'Viewer\',\'2017-06-20\',\'2017-06-20\'),\n (-1,-1, 2,\'Editor\',\'2017-06-20\',\'2017-06-20\')\n ',1,'','2022-01-01 00:00:00'), + ('delete acl rules for deleted dashboards and folders','DELETE FROM dashboard_acl WHERE dashboard_id NOT IN (SELECT id FROM dashboard) AND dashboard_id != -1',1,'','2022-01-01 00:00:00'), + ('create tag table','CREATE TABLE IF NOT EXISTS `tag` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `key` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `value` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index tag.key_value','CREATE UNIQUE INDEX `UQE_tag_key_value` ON `tag` (`key`,`value`);',1,'','2022-01-01 00:00:00'), + ('create login attempt table','CREATE TABLE IF NOT EXISTS `login_attempt` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `username` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `ip_address` VARCHAR(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index login_attempt.username','CREATE INDEX `IDX_login_attempt_username` ON `login_attempt` (`username`);',1,'','2022-01-01 00:00:00'), + ('drop index IDX_login_attempt_username - v1','DROP INDEX `IDX_login_attempt_username` ON `login_attempt`',1,'','2022-01-01 00:00:00'), + ('Rename table login_attempt to login_attempt_tmp_qwerty - v1','ALTER TABLE `login_attempt` RENAME TO `login_attempt_tmp_qwerty`',1,'','2022-01-01 00:00:00'), + ('create login_attempt v2','CREATE TABLE IF NOT EXISTS `login_attempt` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `username` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `ip_address` VARCHAR(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created` INT NOT NULL DEFAULT 0\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create index IDX_login_attempt_username - v2','CREATE INDEX `IDX_login_attempt_username` ON `login_attempt` (`username`);',1,'','2022-01-01 00:00:00'), + ('copy login_attempt v1 to v2','INSERT INTO `login_attempt` (`id`\n, `username`\n, `ip_address`)\nSELECT `id`\n, `username`\n, `ip_address`\nFROM `login_attempt_tmp_qwerty`',1,'','2022-01-01 00:00:00'), + ('drop login_attempt_tmp_qwerty','DROP TABLE IF EXISTS `login_attempt_tmp_qwerty`',1,'','2022-01-01 00:00:00'), + ('increase login_attempt.ip_address column length for IPv6 support','ALTER TABLE login_attempt MODIFY ip_address VARCHAR(50);',1,'','2022-01-01 00:00:00'), + ('alter table login_attempt alter column created type to bigint','ALTER TABLE login_attempt MODIFY created BIGINT;',1,'','2022-01-01 00:00:00'), + ('create user auth table','CREATE TABLE IF NOT EXISTS `user_auth` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `user_id` BIGINT(20) NOT NULL\n, `auth_module` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `auth_id` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create index IDX_user_auth_auth_module_auth_id - v1','CREATE INDEX `IDX_user_auth_auth_module_auth_id` ON `user_auth` (`auth_module`,`auth_id`);',1,'','2022-01-01 00:00:00'), + ('alter user_auth.auth_id to length 190','ALTER TABLE user_auth MODIFY auth_id VARCHAR(190);',1,'','2022-01-01 00:00:00'), + ('Add OAuth access token to user_auth','alter table `user_auth` ADD COLUMN `o_auth_access_token` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Add OAuth refresh token to user_auth','alter table `user_auth` ADD COLUMN `o_auth_refresh_token` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Add OAuth token type to user_auth','alter table `user_auth` ADD COLUMN `o_auth_token_type` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Add OAuth expiry to user_auth','alter table `user_auth` ADD COLUMN `o_auth_expiry` DATETIME NULL ',1,'','2022-01-01 00:00:00'), + ('Add index to user_id column in user_auth','CREATE INDEX `IDX_user_auth_user_id` ON `user_auth` (`user_id`);',1,'','2022-01-01 00:00:00'), + ('Add OAuth ID token to user_auth','alter table `user_auth` ADD COLUMN `o_auth_id_token` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Add user_unique_id to user_auth','alter table `user_auth` ADD COLUMN `external_uid` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('create server_lock table','CREATE TABLE IF NOT EXISTS `server_lock` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `operation_uid` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `version` BIGINT(20) NOT NULL\n, `last_execution` BIGINT(20) NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index server_lock.operation_uid','CREATE UNIQUE INDEX `UQE_server_lock_operation_uid` ON `server_lock` (`operation_uid`);',1,'','2022-01-01 00:00:00'), + ('create user auth token table','CREATE TABLE IF NOT EXISTS `user_auth_token` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `user_id` BIGINT(20) NOT NULL\n, `auth_token` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `prev_auth_token` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `user_agent` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `client_ip` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `auth_token_seen` TINYINT(1) NOT NULL\n, `seen_at` INT NULL\n, `rotated_at` INT NOT NULL\n, `created_at` INT NOT NULL\n, `updated_at` INT NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add unique index user_auth_token.auth_token','CREATE UNIQUE INDEX `UQE_user_auth_token_auth_token` ON `user_auth_token` (`auth_token`);',1,'','2022-01-01 00:00:00'), + ('add unique index user_auth_token.prev_auth_token','CREATE UNIQUE INDEX `UQE_user_auth_token_prev_auth_token` ON `user_auth_token` (`prev_auth_token`);',1,'','2022-01-01 00:00:00'), + ('add index user_auth_token.user_id','CREATE INDEX `IDX_user_auth_token_user_id` ON `user_auth_token` (`user_id`);',1,'','2022-01-01 00:00:00'), + ('Add revoked_at to the user auth token','alter table `user_auth_token` ADD COLUMN `revoked_at` INT NULL ',1,'','2022-01-01 00:00:00'), + ('add index user_auth_token.revoked_at','CREATE INDEX `IDX_user_auth_token_revoked_at` ON `user_auth_token` (`revoked_at`);',1,'','2022-01-01 00:00:00'), + ('add external_session_id to user_auth_token','alter table `user_auth_token` ADD COLUMN `external_session_id` BIGINT(20) NULL ',1,'','2022-01-01 00:00:00'), + ('create cache_data table','CREATE TABLE IF NOT EXISTS `cache_data` (\n`cache_key` VARCHAR(168) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci PRIMARY KEY NOT NULL\n, `data` BLOB NOT NULL\n, `expires` INTEGER(255) NOT NULL\n, `created_at` INTEGER(255) NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add unique index cache_data.cache_key','CREATE UNIQUE INDEX `UQE_cache_data_cache_key` ON `cache_data` (`cache_key`);',1,'','2022-01-01 00:00:00'), + ('create short_url table v1','CREATE TABLE IF NOT EXISTS `short_url` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `path` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created_by` INT NOT NULL\n, `created_at` INT NOT NULL\n, `last_seen_at` INT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index short_url.org_id-uid','CREATE UNIQUE INDEX `UQE_short_url_org_id_uid` ON `short_url` (`org_id`,`uid`);',1,'','2022-01-01 00:00:00'), + ('alter table short_url alter column created_by type to bigint','ALTER TABLE short_url MODIFY created_by BIGINT;',1,'','2022-01-01 00:00:00'), + ('delete alert_definition table','DROP TABLE IF EXISTS `alert_definition`',1,'','2022-01-01 00:00:00'), + ('recreate alert_definition table','CREATE TABLE IF NOT EXISTS `alert_definition` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `title` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `condition` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `data` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `updated` DATETIME NOT NULL\n, `interval_seconds` BIGINT(20) NOT NULL DEFAULT 60\n, `version` INT NOT NULL DEFAULT 0\n, `uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 0\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index in alert_definition on org_id and title columns','CREATE INDEX `IDX_alert_definition_org_id_title` ON `alert_definition` (`org_id`,`title`);',1,'','2022-01-01 00:00:00'), + ('add index in alert_definition on org_id and uid columns','CREATE INDEX `IDX_alert_definition_org_id_uid` ON `alert_definition` (`org_id`,`uid`);',1,'','2022-01-01 00:00:00'), + ('alter alert_definition table data column to mediumtext in mysql','ALTER TABLE alert_definition MODIFY data MEDIUMTEXT;',1,'','2022-01-01 00:00:00'), + ('drop index in alert_definition on org_id and title columns','DROP INDEX `IDX_alert_definition_org_id_title` ON `alert_definition`',1,'','2022-01-01 00:00:00'), + ('drop index in alert_definition on org_id and uid columns','DROP INDEX `IDX_alert_definition_org_id_uid` ON `alert_definition`',1,'','2022-01-01 00:00:00'), + ('add unique index in alert_definition on org_id and title columns','CREATE UNIQUE INDEX `UQE_alert_definition_org_id_title` ON `alert_definition` (`org_id`,`title`);',1,'','2022-01-01 00:00:00'), + ('add unique index in alert_definition on org_id and uid columns','CREATE UNIQUE INDEX `UQE_alert_definition_org_id_uid` ON `alert_definition` (`org_id`,`uid`);',1,'','2022-01-01 00:00:00'), + ('Add column paused in alert_definition','alter table `alert_definition` ADD COLUMN `paused` TINYINT(1) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('drop alert_definition table','DROP TABLE IF EXISTS `alert_definition`',1,'','2022-01-01 00:00:00'), + ('delete alert_definition_version table','DROP TABLE IF EXISTS `alert_definition_version`',1,'','2022-01-01 00:00:00'), + ('recreate alert_definition_version table','CREATE TABLE IF NOT EXISTS `alert_definition_version` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `alert_definition_id` BIGINT(20) NOT NULL\n, `alert_definition_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 0\n, `parent_version` INT NOT NULL\n, `restored_from` INT NOT NULL\n, `version` INT NOT NULL\n, `created` DATETIME NOT NULL\n, `title` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `condition` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `data` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `interval_seconds` BIGINT(20) NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index in alert_definition_version table on alert_definition_id and version columns','CREATE UNIQUE INDEX `UQE_alert_definition_version_alert_definition_id_version` ON `alert_definition_version` (`alert_definition_id`,`version`);',1,'','2022-01-01 00:00:00'), + ('add index in alert_definition_version table on alert_definition_uid and version columns','CREATE UNIQUE INDEX `UQE_alert_definition_version_alert_definition_uid_version` ON `alert_definition_version` (`alert_definition_uid`,`version`);',1,'','2022-01-01 00:00:00'), + ('alter alert_definition_version table data column to mediumtext in mysql','ALTER TABLE alert_definition_version MODIFY data MEDIUMTEXT;',1,'','2022-01-01 00:00:00'), + ('drop alert_definition_version table','DROP TABLE IF EXISTS `alert_definition_version`',1,'','2022-01-01 00:00:00'), + ('create alert_instance table','CREATE TABLE IF NOT EXISTS `alert_instance` (\n`def_org_id` BIGINT(20) NOT NULL\n, `def_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 0\n, `labels` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `labels_hash` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `current_state` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `current_state_since` BIGINT(20) NOT NULL\n, `last_eval_time` BIGINT(20) NOT NULL\n, PRIMARY KEY ( `def_org_id`,`def_uid`,`labels_hash` )) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index in alert_instance table on def_org_id, def_uid and current_state columns','CREATE INDEX `IDX_alert_instance_def_org_id_def_uid_current_state` ON `alert_instance` (`def_org_id`,`def_uid`,`current_state`);',1,'','2022-01-01 00:00:00'), + ('add index in alert_instance table on def_org_id, current_state columns','CREATE INDEX `IDX_alert_instance_def_org_id_current_state` ON `alert_instance` (`def_org_id`,`current_state`);',1,'','2022-01-01 00:00:00'), + ('add column current_state_end to alert_instance','alter table `alert_instance` ADD COLUMN `current_state_end` BIGINT(20) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('remove index def_org_id, def_uid, current_state on alert_instance','DROP INDEX `IDX_alert_instance_def_org_id_def_uid_current_state` ON `alert_instance`',1,'','2022-01-01 00:00:00'), + ('remove index def_org_id, current_state on alert_instance','DROP INDEX `IDX_alert_instance_def_org_id_current_state` ON `alert_instance`',1,'','2022-01-01 00:00:00'), + ('rename def_org_id to rule_org_id in alert_instance','ALTER TABLE alert_instance CHANGE def_org_id rule_org_id BIGINT;',1,'','2022-01-01 00:00:00'), + ('rename def_uid to rule_uid in alert_instance','ALTER TABLE alert_instance CHANGE def_uid rule_uid VARCHAR(40);',1,'','2022-01-01 00:00:00'), + ('add index rule_org_id, rule_uid, current_state on alert_instance','CREATE INDEX `IDX_alert_instance_rule_org_id_rule_uid_current_state` ON `alert_instance` (`rule_org_id`,`rule_uid`,`current_state`);',1,'','2022-01-01 00:00:00'), + ('add index rule_org_id, current_state on alert_instance','CREATE INDEX `IDX_alert_instance_rule_org_id_current_state` ON `alert_instance` (`rule_org_id`,`current_state`);',1,'','2022-01-01 00:00:00'), + ('add current_reason column related to current_state','alter table `alert_instance` ADD COLUMN `current_reason` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add result_fingerprint column to alert_instance','alter table `alert_instance` ADD COLUMN `result_fingerprint` VARCHAR(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('create alert_rule table','CREATE TABLE IF NOT EXISTS `alert_rule` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `title` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `condition` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `data` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `updated` DATETIME NOT NULL\n, `interval_seconds` BIGINT(20) NOT NULL DEFAULT 60\n, `version` INT NOT NULL DEFAULT 0\n, `uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 0\n, `namespace_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `rule_group` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `no_data_state` VARCHAR(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT \'NoData\'\n, `exec_err_state` VARCHAR(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT \'Alerting\'\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index in alert_rule on org_id and title columns','CREATE UNIQUE INDEX `UQE_alert_rule_org_id_title` ON `alert_rule` (`org_id`,`title`);',1,'','2022-01-01 00:00:00'), + ('add index in alert_rule on org_id and uid columns','CREATE UNIQUE INDEX `UQE_alert_rule_org_id_uid` ON `alert_rule` (`org_id`,`uid`);',1,'','2022-01-01 00:00:00'), + ('add index in alert_rule on org_id, namespace_uid, group_uid columns','CREATE INDEX `IDX_alert_rule_org_id_namespace_uid_rule_group` ON `alert_rule` (`org_id`,`namespace_uid`,`rule_group`);',1,'','2022-01-01 00:00:00'), + ('alter alert_rule table data column to mediumtext in mysql','ALTER TABLE alert_rule MODIFY data MEDIUMTEXT;',1,'','2022-01-01 00:00:00'), + ('add column for to alert_rule','alter table `alert_rule` ADD COLUMN `for` BIGINT(20) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('add column annotations to alert_rule','alter table `alert_rule` ADD COLUMN `annotations` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add column labels to alert_rule','alter table `alert_rule` ADD COLUMN `labels` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('remove unique index from alert_rule on org_id, title columns','DROP INDEX `UQE_alert_rule_org_id_title` ON `alert_rule`',1,'','2022-01-01 00:00:00'), + ('add index in alert_rule on org_id, namespase_uid and title columns','CREATE UNIQUE INDEX `UQE_alert_rule_org_id_namespace_uid_title` ON `alert_rule` (`org_id`,`namespace_uid`,`title`);',1,'','2022-01-01 00:00:00'), + ('add dashboard_uid column to alert_rule','alter table `alert_rule` ADD COLUMN `dashboard_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add panel_id column to alert_rule','alter table `alert_rule` ADD COLUMN `panel_id` BIGINT(20) NULL ',1,'','2022-01-01 00:00:00'), + ('add index in alert_rule on org_id, dashboard_uid and panel_id columns','CREATE INDEX `IDX_alert_rule_org_id_dashboard_uid_panel_id` ON `alert_rule` (`org_id`,`dashboard_uid`,`panel_id`);',1,'','2022-01-01 00:00:00'), + ('add rule_group_idx column to alert_rule','alter table `alert_rule` ADD COLUMN `rule_group_idx` INT NOT NULL DEFAULT 1 ',1,'','2022-01-01 00:00:00'), + ('add is_paused column to alert_rule table','alter table `alert_rule` ADD COLUMN `is_paused` TINYINT(1) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('fix is_paused column for alert_rule table','',1,'','2022-01-01 00:00:00'), + ('alter table alert_rule alter column rule_group_idx type to bigint','ALTER TABLE alert_rule MODIFY rule_group_idx BIGINT;',1,'','2022-01-01 00:00:00'), + ('create alert_rule_version table','CREATE TABLE IF NOT EXISTS `alert_rule_version` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `rule_org_id` BIGINT(20) NOT NULL\n, `rule_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 0\n, `rule_namespace_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `rule_group` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `parent_version` INT NOT NULL\n, `restored_from` INT NOT NULL\n, `version` INT NOT NULL\n, `created` DATETIME NOT NULL\n, `title` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `condition` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `data` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `interval_seconds` BIGINT(20) NOT NULL\n, `no_data_state` VARCHAR(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT \'NoData\'\n, `exec_err_state` VARCHAR(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT \'Alerting\'\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index in alert_rule_version table on rule_org_id, rule_uid and version columns','CREATE UNIQUE INDEX `UQE_alert_rule_version_rule_org_id_rule_uid_version` ON `alert_rule_version` (`rule_org_id`,`rule_uid`,`version`);',1,'','2022-01-01 00:00:00'), + ('add index in alert_rule_version table on rule_org_id, rule_namespace_uid and rule_group columns','CREATE INDEX `IDX_alert_rule_version_rule_org_id_rule_namespace_uid_rule_group` ON `alert_rule_version` (`rule_org_id`,`rule_namespace_uid`,`rule_group`);',1,'','2022-01-01 00:00:00'), + ('alter alert_rule_version table data column to mediumtext in mysql','ALTER TABLE alert_rule_version MODIFY data MEDIUMTEXT;',1,'','2022-01-01 00:00:00'), + ('add column for to alert_rule_version','alter table `alert_rule_version` ADD COLUMN `for` BIGINT(20) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('add column annotations to alert_rule_version','alter table `alert_rule_version` ADD COLUMN `annotations` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add column labels to alert_rule_version','alter table `alert_rule_version` ADD COLUMN `labels` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add rule_group_idx column to alert_rule_version','alter table `alert_rule_version` ADD COLUMN `rule_group_idx` INT NOT NULL DEFAULT 1 ',1,'','2022-01-01 00:00:00'), + ('add is_paused column to alert_rule_versions table','alter table `alert_rule_version` ADD COLUMN `is_paused` TINYINT(1) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('fix is_paused column for alert_rule_version table','',1,'','2022-01-01 00:00:00'), + ('alter table alert_rule_version alter column rule_group_idx type to bigint','ALTER TABLE alert_rule_version MODIFY rule_group_idx BIGINT;',1,'','2022-01-01 00:00:00'), + ('create_alert_configuration_table','CREATE TABLE IF NOT EXISTS `alert_configuration` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `alertmanager_configuration` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `configuration_version` VARCHAR(3) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created_at` INT NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('Add column default in alert_configuration','alter table `alert_configuration` ADD COLUMN `default` TINYINT(1) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('alert alert_configuration alertmanager_configuration column from TEXT to MEDIUMTEXT if mysql','ALTER TABLE alert_configuration MODIFY alertmanager_configuration MEDIUMTEXT;',1,'','2022-01-01 00:00:00'), + ('add column org_id in alert_configuration','alter table `alert_configuration` ADD COLUMN `org_id` BIGINT(20) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('add index in alert_configuration table on org_id column','CREATE INDEX `IDX_alert_configuration_org_id` ON `alert_configuration` (`org_id`);',1,'','2022-01-01 00:00:00'), + ('add configuration_hash column to alert_configuration','alter table `alert_configuration` ADD COLUMN `configuration_hash` VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT \'not-yet-calculated\' ',1,'','2022-01-01 00:00:00'), + ('create_ngalert_configuration_table','CREATE TABLE IF NOT EXISTS `ngalert_configuration` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `alertmanagers` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `created_at` INT NOT NULL\n, `updated_at` INT NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index in ngalert_configuration on org_id column','CREATE UNIQUE INDEX `UQE_ngalert_configuration_org_id` ON `ngalert_configuration` (`org_id`);',1,'','2022-01-01 00:00:00'), + ('add column send_alerts_to in ngalert_configuration','alter table `ngalert_configuration` ADD COLUMN `send_alerts_to` SMALLINT NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('create provenance_type table','CREATE TABLE IF NOT EXISTS `provenance_type` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `record_key` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `record_type` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `provenance` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index to uniquify (record_key, record_type, org_id) columns','CREATE UNIQUE INDEX `UQE_provenance_type_record_type_record_key_org_id` ON `provenance_type` (`record_type`,`record_key`,`org_id`);',1,'','2022-01-01 00:00:00'), + ('create alert_image table','CREATE TABLE IF NOT EXISTS `alert_image` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `token` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `path` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `url` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created_at` DATETIME NOT NULL\n, `expires_at` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add unique index on token to alert_image table','CREATE UNIQUE INDEX `UQE_alert_image_token` ON `alert_image` (`token`);',1,'','2022-01-01 00:00:00'), + ('support longer URLs in alert_image table','ALTER TABLE alert_image MODIFY url VARCHAR(2048) NOT NULL;',1,'','2022-01-01 00:00:00'), + ('create_alert_configuration_history_table','CREATE TABLE IF NOT EXISTS `alert_configuration_history` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `org_id` BIGINT(20) NOT NULL DEFAULT 0\n, `alertmanager_configuration` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `configuration_hash` VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT \'not-yet-calculated\'\n, `configuration_version` VARCHAR(3) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created_at` INT NOT NULL\n, `default` TINYINT(1) NOT NULL DEFAULT 0\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('drop non-unique orgID index on alert_configuration','DROP INDEX `IDX_alert_configuration_org_id` ON `alert_configuration`',1,'','2022-01-01 00:00:00'), + ('drop unique orgID index on alert_configuration if exists','DROP INDEX `UQE_alert_configuration_org_id` ON `alert_configuration`',1,'','2022-01-01 00:00:00'), + ('extract alertmanager configuration history to separate table','code migration',1,'','2022-01-01 00:00:00'), + ('add unique index on orgID to alert_configuration','CREATE UNIQUE INDEX `UQE_alert_configuration_org_id` ON `alert_configuration` (`org_id`);',1,'','2022-01-01 00:00:00'), + ('add last_applied column to alert_configuration_history','alter table `alert_configuration_history` ADD COLUMN `last_applied` INT NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('add message column to alert_rule_version','alter table `alert_rule_version` ADD COLUMN `message` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('create library_element table v1','CREATE TABLE IF NOT EXISTS `library_element` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `folder_id` BIGINT(20) NOT NULL\n, `uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `name` VARCHAR(150) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `kind` BIGINT(20) NOT NULL\n, `type` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `description` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `model` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created` DATETIME NOT NULL\n, `created_by` BIGINT(20) NOT NULL\n, `updated` DATETIME NOT NULL\n, `updated_by` BIGINT(20) NOT NULL\n, `version` BIGINT(20) NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index library_element org_id-folder_id-name-kind','CREATE UNIQUE INDEX `UQE_library_element_org_id_folder_id_name_kind` ON `library_element` (`org_id`,`folder_id`,`name`,`kind`);',1,'','2022-01-01 00:00:00'), + ('create library_element_connection table v1','CREATE TABLE IF NOT EXISTS `library_element_connection` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `element_id` BIGINT(20) NOT NULL\n, `kind` BIGINT(20) NOT NULL\n, `connection_id` BIGINT(20) NOT NULL\n, `created` DATETIME NOT NULL\n, `created_by` BIGINT(20) NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index library_element_connection element_id-kind-connection_id','CREATE UNIQUE INDEX `UQE_library_element_connection_element_id_kind_connection_id` ON `library_element_connection` (`element_id`,`kind`,`connection_id`);',1,'','2022-01-01 00:00:00'), + ('add unique index library_element org_id_uid','CREATE UNIQUE INDEX `UQE_library_element_org_id_uid` ON `library_element` (`org_id`,`uid`);',1,'','2022-01-01 00:00:00'), + ('increase max description length to 2048','ALTER TABLE `library_element` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, MODIFY `description` VARCHAR(2048) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL ;',1,'','2022-01-01 00:00:00'), + ('alter library_element model to mediumtext','ALTER TABLE library_element MODIFY model MEDIUMTEXT NOT NULL;',1,'','2022-01-01 00:00:00'), + ('add library_element folder uid','alter table `library_element` ADD COLUMN `folder_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('populate library_element folder_uid','UPDATE library_element\n SET folder_uid = (\n SELECT dashboard.uid\n FROM dashboard\n WHERE library_element.folder_id = dashboard.id AND library_element.org_id = dashboard.org_id\n )',1,'','2022-01-01 00:00:00'), + ('add index library_element org_id-folder_uid-name-kind','CREATE UNIQUE INDEX `UQE_library_element_org_id_folder_uid_name_kind` ON `library_element` (`org_id`,`folder_uid`,`name`,`kind`);',1,'','2022-01-01 00:00:00'), + ('drop unique name in folder index (id)','DROP INDEX `UQE_library_element_org_id_folder_id_name_kind` ON `library_element`',1,'','2022-01-01 00:00:00'), + ('drop unique name in folder index','DROP INDEX `UQE_library_element_org_id_folder_uid_name_kind` ON `library_element`',1,'','2022-01-01 00:00:00'), + ('clone move dashboard alerts to unified alerting','code migration',1,'','2022-01-01 00:00:00'), + ('create data_keys table','CREATE TABLE IF NOT EXISTS `data_keys` (\n`name` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci PRIMARY KEY NOT NULL\n, `active` TINYINT(1) NOT NULL\n, `scope` VARCHAR(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `provider` VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `encrypted_data` BLOB NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create secrets table','CREATE TABLE IF NOT EXISTS `secrets` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `namespace` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `type` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `value` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('rename data_keys name column to id','ALTER TABLE `data_keys` CHANGE `name` `id` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci',1,'','2022-01-01 00:00:00'), + ('add name column into data_keys','alter table `data_keys` ADD COLUMN `name` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT \'\' ',1,'','2022-01-01 00:00:00'), + ('copy data_keys id column values into name','UPDATE data_keys SET name = id',1,'','2022-01-01 00:00:00'), + ('rename data_keys name column to label','ALTER TABLE `data_keys` CHANGE `name` `label` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci',1,'','2022-01-01 00:00:00'), + ('rename data_keys id column back to name','ALTER TABLE `data_keys` CHANGE `id` `name` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci',1,'','2022-01-01 00:00:00'), + ('create kv_store table v1','CREATE TABLE IF NOT EXISTS `kv_store` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `namespace` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `key` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `value` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index kv_store.org_id-namespace-key','CREATE UNIQUE INDEX `UQE_kv_store_org_id_namespace_key` ON `kv_store` (`org_id`,`namespace`,`key`);',1,'','2022-01-01 00:00:00'), + ('update dashboard_uid and panel_id from existing annotations','set dashboard_uid and panel_id migration',1,'','2022-01-01 00:00:00'), + ('create permission table','CREATE TABLE IF NOT EXISTS `permission` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `role_id` BIGINT(20) NOT NULL\n, `action` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `scope` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add unique index permission.role_id','CREATE INDEX `IDX_permission_role_id` ON `permission` (`role_id`);',1,'','2022-01-01 00:00:00'), + ('add unique index role_id_action_scope','CREATE UNIQUE INDEX `UQE_permission_role_id_action_scope` ON `permission` (`role_id`,`action`,`scope`);',1,'','2022-01-01 00:00:00'), + ('create role table','CREATE TABLE IF NOT EXISTS `role` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `name` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `description` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `version` BIGINT(20) NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add column display_name','alter table `role` ADD COLUMN `display_name` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add column group_name','alter table `role` ADD COLUMN `group_name` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add index role.org_id','CREATE INDEX `IDX_role_org_id` ON `role` (`org_id`);',1,'','2022-01-01 00:00:00'), + ('add unique index role_org_id_name','CREATE UNIQUE INDEX `UQE_role_org_id_name` ON `role` (`org_id`,`name`);',1,'','2022-01-01 00:00:00'), + ('add index role_org_id_uid','CREATE UNIQUE INDEX `UQE_role_org_id_uid` ON `role` (`org_id`,`uid`);',1,'','2022-01-01 00:00:00'), + ('create team role table','CREATE TABLE IF NOT EXISTS `team_role` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `team_id` BIGINT(20) NOT NULL\n, `role_id` BIGINT(20) NOT NULL\n, `created` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index team_role.org_id','CREATE INDEX `IDX_team_role_org_id` ON `team_role` (`org_id`);',1,'','2022-01-01 00:00:00'), + ('add unique index team_role_org_id_team_id_role_id','CREATE UNIQUE INDEX `UQE_team_role_org_id_team_id_role_id` ON `team_role` (`org_id`,`team_id`,`role_id`);',1,'','2022-01-01 00:00:00'), + ('add index team_role.team_id','CREATE INDEX `IDX_team_role_team_id` ON `team_role` (`team_id`);',1,'','2022-01-01 00:00:00'), + ('create user role table','CREATE TABLE IF NOT EXISTS `user_role` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `user_id` BIGINT(20) NOT NULL\n, `role_id` BIGINT(20) NOT NULL\n, `created` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index user_role.org_id','CREATE INDEX `IDX_user_role_org_id` ON `user_role` (`org_id`);',1,'','2022-01-01 00:00:00'), + ('add unique index user_role_org_id_user_id_role_id','CREATE UNIQUE INDEX `UQE_user_role_org_id_user_id_role_id` ON `user_role` (`org_id`,`user_id`,`role_id`);',1,'','2022-01-01 00:00:00'), + ('add index user_role.user_id','CREATE INDEX `IDX_user_role_user_id` ON `user_role` (`user_id`);',1,'','2022-01-01 00:00:00'), + ('create builtin role table','CREATE TABLE IF NOT EXISTS `builtin_role` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `role` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `role_id` BIGINT(20) NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index builtin_role.role_id','CREATE INDEX `IDX_builtin_role_role_id` ON `builtin_role` (`role_id`);',1,'','2022-01-01 00:00:00'), + ('add index builtin_role.name','CREATE INDEX `IDX_builtin_role_role` ON `builtin_role` (`role`);',1,'','2022-01-01 00:00:00'), + ('Add column org_id to builtin_role table','alter table `builtin_role` ADD COLUMN `org_id` BIGINT(20) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('add index builtin_role.org_id','CREATE INDEX `IDX_builtin_role_org_id` ON `builtin_role` (`org_id`);',1,'','2022-01-01 00:00:00'), + ('add unique index builtin_role_org_id_role_id_role','CREATE UNIQUE INDEX `UQE_builtin_role_org_id_role_id_role` ON `builtin_role` (`org_id`,`role_id`,`role`);',1,'','2022-01-01 00:00:00'), + ('Remove unique index role_org_id_uid','DROP INDEX `UQE_role_org_id_uid` ON `role`',1,'','2022-01-01 00:00:00'), + ('add unique index role.uid','CREATE UNIQUE INDEX `UQE_role_uid` ON `role` (`uid`);',1,'','2022-01-01 00:00:00'), + ('create seed assignment table','CREATE TABLE IF NOT EXISTS `seed_assignment` (\n`builtin_role` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `role_name` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add unique index builtin_role_role_name','CREATE UNIQUE INDEX `UQE_seed_assignment_builtin_role_role_name` ON `seed_assignment` (`builtin_role`,`role_name`);',1,'','2022-01-01 00:00:00'), + ('add column hidden to role table','alter table `role` ADD COLUMN `hidden` TINYINT(1) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('permission kind migration','alter table `permission` ADD COLUMN `kind` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT \'\' ',1,'','2022-01-01 00:00:00'), + ('permission attribute migration','alter table `permission` ADD COLUMN `attribute` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT \'\' ',1,'','2022-01-01 00:00:00'), + ('permission identifier migration','alter table `permission` ADD COLUMN `identifier` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT \'\' ',1,'','2022-01-01 00:00:00'), + ('add permission identifier index','CREATE INDEX `IDX_permission_identifier` ON `permission` (`identifier`);',1,'','2022-01-01 00:00:00'), + ('add permission action scope role_id index','CREATE UNIQUE INDEX `UQE_permission_action_scope_role_id` ON `permission` (`action`,`scope`,`role_id`);',1,'','2022-01-01 00:00:00'), + ('remove permission role_id action scope index','DROP INDEX `UQE_permission_role_id_action_scope` ON `permission`',1,'','2022-01-01 00:00:00'), + ('add group mapping UID column to user_role table','alter table `user_role` ADD COLUMN `group_mapping_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT \'\' ',1,'','2022-01-01 00:00:00'), + ('add user_role org ID, user ID, role ID, group mapping UID index','CREATE UNIQUE INDEX `UQE_user_role_org_id_user_id_role_id_group_mapping_uid` ON `user_role` (`org_id`,`user_id`,`role_id`,`group_mapping_uid`);',1,'','2022-01-01 00:00:00'), + ('remove user_role org ID, user ID, role ID index','DROP INDEX `UQE_user_role_org_id_user_id_role_id` ON `user_role`',1,'','2022-01-01 00:00:00'), + ('add permission role_id action index','CREATE INDEX `IDX_permission_role_id_action` ON `permission` (`role_id`,`action`);',1,'','2022-01-01 00:00:00'), + ('Remove permission role_id index','DROP INDEX `IDX_permission_role_id` ON `permission`',1,'','2022-01-01 00:00:00'), + ('create query_history table v1','CREATE TABLE IF NOT EXISTS `query_history` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `datasource_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created_by` INT NOT NULL\n, `created_at` INT NOT NULL\n, `comment` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `queries` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index query_history.org_id-created_by-datasource_uid','CREATE INDEX `IDX_query_history_org_id_created_by_datasource_uid` ON `query_history` (`org_id`,`created_by`,`datasource_uid`);',1,'','2022-01-01 00:00:00'), + ('alter table query_history alter column created_by type to bigint','ALTER TABLE query_history MODIFY created_by BIGINT;',1,'','2022-01-01 00:00:00'), + ('create query_history_details table v1','CREATE TABLE IF NOT EXISTS `query_history_details` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `query_history_item_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `datasource_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('rbac disabled migrator','code migration',1,'','2022-01-01 00:00:00'), + ('teams permissions migration','code migration',1,'','2022-01-01 00:00:00'), + ('dashboard permissions','code migration',1,'','2022-01-01 00:00:00'), + ('dashboard permissions uid scopes','code migration',1,'','2022-01-01 00:00:00'), + ('drop managed folder create actions','code migration',1,'','2022-01-01 00:00:00'), + ('alerting notification permissions','code migration',1,'','2022-01-01 00:00:00'), + ('create query_history_star table v1','CREATE TABLE IF NOT EXISTS `query_history_star` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `query_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `user_id` INT NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index query_history.user_id-query_uid','CREATE UNIQUE INDEX `UQE_query_history_star_user_id_query_uid` ON `query_history_star` (`user_id`,`query_uid`);',1,'','2022-01-01 00:00:00'), + ('add column org_id in query_history_star','alter table `query_history_star` ADD COLUMN `org_id` BIGINT(20) NOT NULL DEFAULT 1 ',1,'','2022-01-01 00:00:00'), + ('alter table query_history_star_mig column user_id type to bigint','ALTER TABLE query_history_star MODIFY user_id BIGINT;',1,'','2022-01-01 00:00:00'), + ('create correlation table v1','CREATE TABLE IF NOT EXISTS `correlation` (\n`uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `source_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `target_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `label` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `description` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, PRIMARY KEY ( `uid`,`source_uid` )) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index correlations.uid','CREATE INDEX `IDX_correlation_uid` ON `correlation` (`uid`);',1,'','2022-01-01 00:00:00'), + ('add index correlations.source_uid','CREATE INDEX `IDX_correlation_source_uid` ON `correlation` (`source_uid`);',1,'','2022-01-01 00:00:00'), + ('add correlation config column','alter table `correlation` ADD COLUMN `config` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('drop index IDX_correlation_uid - v1','DROP INDEX `IDX_correlation_uid` ON `correlation`',1,'','2022-01-01 00:00:00'), + ('drop index IDX_correlation_source_uid - v1','DROP INDEX `IDX_correlation_source_uid` ON `correlation`',1,'','2022-01-01 00:00:00'), + ('Rename table correlation to correlation_tmp_qwerty - v1','ALTER TABLE `correlation` RENAME TO `correlation_tmp_qwerty`',1,'','2022-01-01 00:00:00'), + ('create correlation v2','CREATE TABLE IF NOT EXISTS `correlation` (\n`uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `org_id` BIGINT(20) NOT NULL DEFAULT 0\n, `source_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `target_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `label` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `description` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `config` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, PRIMARY KEY ( `uid`,`org_id`,`source_uid` )) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create index IDX_correlation_uid - v2','CREATE INDEX `IDX_correlation_uid` ON `correlation` (`uid`);',1,'','2022-01-01 00:00:00'), + ('create index IDX_correlation_source_uid - v2','CREATE INDEX `IDX_correlation_source_uid` ON `correlation` (`source_uid`);',1,'','2022-01-01 00:00:00'), + ('create index IDX_correlation_org_id - v2','CREATE INDEX `IDX_correlation_org_id` ON `correlation` (`org_id`);',1,'','2022-01-01 00:00:00'), + ('copy correlation v1 to v2','INSERT INTO `correlation` (`target_uid`\n, `label`\n, `description`\n, `config`\n, `uid`\n, `source_uid`)\nSELECT `target_uid`\n, `label`\n, `description`\n, `config`\n, `uid`\n, `source_uid`\nFROM `correlation_tmp_qwerty`',1,'','2022-01-01 00:00:00'), + ('drop correlation_tmp_qwerty','DROP TABLE IF EXISTS `correlation_tmp_qwerty`',1,'','2022-01-01 00:00:00'), + ('add provisioning column','alter table `correlation` ADD COLUMN `provisioned` TINYINT(1) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('add type column','alter table `correlation` ADD COLUMN `type` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT \'query\' ',1,'','2022-01-01 00:00:00'), + ('create entity_events table','CREATE TABLE IF NOT EXISTS `entity_event` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `entity_id` VARCHAR(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `event_type` VARCHAR(8) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created` BIGINT(20) NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create dashboard public config v1','CREATE TABLE IF NOT EXISTS `dashboard_public_config` (\n`uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci PRIMARY KEY NOT NULL\n, `dashboard_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `time_settings` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `refresh_rate` INT NOT NULL DEFAULT 30\n, `template_variables` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('drop index UQE_dashboard_public_config_uid - v1','DROP INDEX `UQE_dashboard_public_config_uid` ON `dashboard_public_config`',1,'','2022-01-01 00:00:00'), + ('drop index IDX_dashboard_public_config_org_id_dashboard_uid - v1','DROP INDEX `IDX_dashboard_public_config_org_id_dashboard_uid` ON `dashboard_public_config`',1,'','2022-01-01 00:00:00'), + ('Drop old dashboard public config table','DROP TABLE IF EXISTS `dashboard_public_config`',1,'','2022-01-01 00:00:00'), + ('recreate dashboard public config v1','CREATE TABLE IF NOT EXISTS `dashboard_public_config` (\n`uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci PRIMARY KEY NOT NULL\n, `dashboard_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `time_settings` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `refresh_rate` INT NOT NULL DEFAULT 30\n, `template_variables` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create index UQE_dashboard_public_config_uid - v1','CREATE UNIQUE INDEX `UQE_dashboard_public_config_uid` ON `dashboard_public_config` (`uid`);',1,'','2022-01-01 00:00:00'), + ('create index IDX_dashboard_public_config_org_id_dashboard_uid - v1','CREATE INDEX `IDX_dashboard_public_config_org_id_dashboard_uid` ON `dashboard_public_config` (`org_id`,`dashboard_uid`);',1,'','2022-01-01 00:00:00'), + ('drop index UQE_dashboard_public_config_uid - v2','DROP INDEX `UQE_dashboard_public_config_uid` ON `dashboard_public_config`',1,'','2022-01-01 00:00:00'), + ('drop index IDX_dashboard_public_config_org_id_dashboard_uid - v2','DROP INDEX `IDX_dashboard_public_config_org_id_dashboard_uid` ON `dashboard_public_config`',1,'','2022-01-01 00:00:00'), + ('Drop public config table','DROP TABLE IF EXISTS `dashboard_public_config`',1,'','2022-01-01 00:00:00'), + ('Recreate dashboard public config v2','CREATE TABLE IF NOT EXISTS `dashboard_public_config` (\n`uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci PRIMARY KEY NOT NULL\n, `dashboard_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `time_settings` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `template_variables` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `access_token` VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created_by` INT NOT NULL\n, `updated_by` INT NULL\n, `created_at` DATETIME NOT NULL\n, `updated_at` DATETIME NULL\n, `is_enabled` TINYINT(1) NOT NULL DEFAULT 0\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create index UQE_dashboard_public_config_uid - v2','CREATE UNIQUE INDEX `UQE_dashboard_public_config_uid` ON `dashboard_public_config` (`uid`);',1,'','2022-01-01 00:00:00'), + ('create index IDX_dashboard_public_config_org_id_dashboard_uid - v2','CREATE INDEX `IDX_dashboard_public_config_org_id_dashboard_uid` ON `dashboard_public_config` (`org_id`,`dashboard_uid`);',1,'','2022-01-01 00:00:00'), + ('create index UQE_dashboard_public_config_access_token - v2','CREATE UNIQUE INDEX `UQE_dashboard_public_config_access_token` ON `dashboard_public_config` (`access_token`);',1,'','2022-01-01 00:00:00'), + ('Rename table dashboard_public_config to dashboard_public - v2','ALTER TABLE `dashboard_public_config` RENAME TO `dashboard_public`',1,'','2022-01-01 00:00:00'), + ('add annotations_enabled column','alter table `dashboard_public` ADD COLUMN `annotations_enabled` TINYINT(1) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('add time_selection_enabled column','alter table `dashboard_public` ADD COLUMN `time_selection_enabled` TINYINT(1) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('delete orphaned public dashboards','DELETE FROM dashboard_public WHERE dashboard_uid NOT IN (SELECT uid FROM dashboard)',1,'','2022-01-01 00:00:00'), + ('add share column','alter table `dashboard_public` ADD COLUMN `share` VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT \'public\' ',1,'','2022-01-01 00:00:00'), + ('backfill empty share column fields with default of public','UPDATE dashboard_public SET share=\'public\' WHERE share=\'\'',1,'','2022-01-01 00:00:00'), + ('create file table','CREATE TABLE IF NOT EXISTS `file` (\n`path` VARCHAR(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `path_hash` VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `parent_folder_path_hash` VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `contents` BLOB NOT NULL\n, `etag` VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `cache_control` VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `content_disposition` VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `updated` DATETIME NOT NULL\n, `created` DATETIME NOT NULL\n, `size` BIGINT(20) NOT NULL\n, `mime_type` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('file table idx: path natural pk','CREATE UNIQUE INDEX `UQE_file_path_hash` ON `file` (`path_hash`);',1,'','2022-01-01 00:00:00'), + ('file table idx: parent_folder_path_hash fast folder retrieval','CREATE INDEX `IDX_file_parent_folder_path_hash` ON `file` (`parent_folder_path_hash`);',1,'','2022-01-01 00:00:00'), + ('create file_meta table','CREATE TABLE IF NOT EXISTS `file_meta` (\n`path_hash` VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `key` VARCHAR(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `value` VARCHAR(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('file table idx: path key','CREATE UNIQUE INDEX `UQE_file_meta_path_hash_key` ON `file_meta` (`path_hash`,`key`);',1,'','2022-01-01 00:00:00'), + ('set path collation in file table','',1,'','2022-01-01 00:00:00'), + ('migrate contents column to mediumblob for MySQL','ALTER TABLE file MODIFY contents MEDIUMBLOB;',1,'','2022-01-01 00:00:00'), + ('drop my_row_id and add primary key to file table if my_row_id exists (auto-generated mysql column)','\n ALTER TABLE file\n DROP PRIMARY KEY,\n DROP COLUMN my_row_id,\n DROP INDEX UQE_file_path_hash,\n ADD PRIMARY KEY (path_hash);\n ',1,'','2022-01-01 00:00:00'), + ('drop file_path unique index from file table if it exists (mysql)','ALTER TABLE file DROP INDEX UQE_file_path_hash',1,'','2022-01-01 00:00:00'), + ('add primary key to file table if it doesn\'t exist (mysql)','ALTER TABLE file ADD PRIMARY KEY (path_hash);',1,'','2022-01-01 00:00:00'), + ('add primary key to file table (postgres and sqlite)','',1,'','2022-01-01 00:00:00'), + ('drop my_row_id and add primary key to file_meta table if my_row_id exists (auto-generated mysql column)','\n ALTER TABLE file_meta\n DROP PRIMARY KEY,\n DROP COLUMN my_row_id,\n DROP INDEX UQE_file_meta_path_hash_key,\n ADD PRIMARY KEY (path_hash, `key`);\n ',1,'','2022-01-01 00:00:00'), + ('drop file_path unique index from file_meta table if it exists (mysql)','ALTER TABLE file_meta DROP INDEX UQE_file_meta_path_hash_key',1,'','2022-01-01 00:00:00'), + ('add primary key to file_meta table if it doesn\'t exist (mysql)','ALTER TABLE file_meta ADD PRIMARY KEY (path_hash, `key`);',1,'','2022-01-01 00:00:00'), + ('add primary key to file_meta table (postgres and sqlite)','',1,'','2022-01-01 00:00:00'), + ('managed permissions migration','code migration',1,'','2022-01-01 00:00:00'), + ('managed folder permissions alert actions migration','code migration',1,'','2022-01-01 00:00:00'), + ('RBAC action name migrator','code migration',1,'','2022-01-01 00:00:00'), + ('Add UID column to playlist','alter table `playlist` ADD COLUMN `uid` VARCHAR(80) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('Update uid column values in playlist','UPDATE playlist SET uid=id;',1,'','2022-01-01 00:00:00'), + ('Add index for uid in playlist','CREATE UNIQUE INDEX `UQE_playlist_org_id_uid` ON `playlist` (`org_id`,`uid`);',1,'','2022-01-01 00:00:00'), + ('update group index for alert rules','code migration',1,'','2022-01-01 00:00:00'), + ('managed folder permissions alert actions repeated migration','code migration',1,'','2022-01-01 00:00:00'), + ('admin only folder/dashboard permission','code migration',1,'','2022-01-01 00:00:00'), + ('add action column to seed_assignment','alter table `seed_assignment` ADD COLUMN `action` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add scope column to seed_assignment','alter table `seed_assignment` ADD COLUMN `scope` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('remove unique index builtin_role_role_name before nullable update','DROP INDEX `UQE_seed_assignment_builtin_role_role_name` ON `seed_assignment`',1,'','2022-01-01 00:00:00'), + ('update seed_assignment role_name column to nullable','ALTER TABLE seed_assignment MODIFY role_name VARCHAR(190) DEFAULT NULL;',1,'','2022-01-01 00:00:00'), + ('add unique index builtin_role_name back','CREATE UNIQUE INDEX `UQE_seed_assignment_builtin_role_role_name` ON `seed_assignment` (`builtin_role`,`role_name`);',1,'','2022-01-01 00:00:00'), + ('add unique index builtin_role_action_scope','CREATE UNIQUE INDEX `UQE_seed_assignment_builtin_role_action_scope` ON `seed_assignment` (`builtin_role`,`action`,`scope`);',1,'','2022-01-01 00:00:00'), + ('add primary key to seed_assigment','code migration',1,'','2022-01-01 00:00:00'), + ('add origin column to seed_assignment','alter table `seed_assignment` ADD COLUMN `origin` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add origin to plugin seed_assignment','code migration',1,'','2022-01-01 00:00:00'), + ('prevent seeding OnCall access','code migration',1,'','2022-01-01 00:00:00'), + ('managed folder permissions alert actions repeated fixed migration','code migration',1,'','2022-01-01 00:00:00'), + ('managed folder permissions library panel actions migration','code migration',1,'','2022-01-01 00:00:00'), + ('migrate external alertmanagers to datsourcse','migrate external alertmanagers to datasource',1,'','2022-01-01 00:00:00'), + ('create folder table','CREATE TABLE IF NOT EXISTS `folder` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `title` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `description` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `parent_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('Add index for parent_uid','CREATE INDEX `IDX_folder_parent_uid_org_id` ON `folder` (`parent_uid`,`org_id`);',1,'','2022-01-01 00:00:00'), + ('Add unique index for folder.uid and folder.org_id','CREATE UNIQUE INDEX `UQE_folder_uid_org_id` ON `folder` (`uid`,`org_id`);',1,'','2022-01-01 00:00:00'), + ('Update folder title length','ALTER TABLE `folder` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, MODIFY `title` VARCHAR(189) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL ;',1,'','2022-01-01 00:00:00'), + ('Add unique index for folder.title and folder.parent_uid','CREATE UNIQUE INDEX `UQE_folder_title_parent_uid` ON `folder` (`title`,`parent_uid`);',1,'','2022-01-01 00:00:00'), + ('Remove unique index for folder.title and folder.parent_uid','DROP INDEX `UQE_folder_title_parent_uid` ON `folder`',1,'','2022-01-01 00:00:00'), + ('Add unique index for title, parent_uid, and org_id','CREATE UNIQUE INDEX `UQE_folder_title_parent_uid_org_id` ON `folder` (`title`,`parent_uid`,`org_id`);',1,'','2022-01-01 00:00:00'), + ('Sync dashboard and folder table','\n INSERT INTO folder (uid, org_id, title, created, updated)\n SELECT * FROM (SELECT uid, org_id, title, created, updated FROM dashboard WHERE is_folder = 1) AS derived\n ON DUPLICATE KEY UPDATE title=derived.title, updated=derived.updated\n ',1,'','2022-01-01 00:00:00'), + ('Remove ghost folders from the folder table','\n DELETE FROM folder WHERE NOT EXISTS\n (SELECT 1 FROM dashboard WHERE dashboard.uid = folder.uid AND dashboard.org_id = folder.org_id AND dashboard.is_folder = true)\n ',1,'','2022-01-01 00:00:00'), + ('Remove unique index UQE_folder_uid_org_id','DROP INDEX `UQE_folder_uid_org_id` ON `folder`',1,'','2022-01-01 00:00:00'), + ('Add unique index UQE_folder_org_id_uid','CREATE UNIQUE INDEX `UQE_folder_org_id_uid` ON `folder` (`org_id`,`uid`);',1,'','2022-01-01 00:00:00'), + ('Remove unique index UQE_folder_title_parent_uid_org_id','DROP INDEX `UQE_folder_title_parent_uid_org_id` ON `folder`',1,'','2022-01-01 00:00:00'), + ('Add unique index UQE_folder_org_id_parent_uid_title','CREATE UNIQUE INDEX `UQE_folder_org_id_parent_uid_title` ON `folder` (`org_id`,`parent_uid`,`title`);',1,'','2022-01-01 00:00:00'), + ('Remove index IDX_folder_parent_uid_org_id','DROP INDEX `IDX_folder_parent_uid_org_id` ON `folder`',1,'','2022-01-01 00:00:00'), + ('Remove unique index UQE_folder_org_id_parent_uid_title','DROP INDEX `UQE_folder_org_id_parent_uid_title` ON `folder`',1,'','2022-01-01 00:00:00'), + ('Add index IDX_folder_org_id_parent_uid','CREATE INDEX `IDX_folder_org_id_parent_uid` ON `folder` (`org_id`,`parent_uid`);',1,'','2022-01-01 00:00:00'), + ('create anon_device table','CREATE TABLE IF NOT EXISTS `anon_device` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `client_ip` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created_at` DATETIME NOT NULL\n, `device_id` VARCHAR(127) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `updated_at` DATETIME NOT NULL\n, `user_agent` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add unique index anon_device.device_id','CREATE UNIQUE INDEX `UQE_anon_device_device_id` ON `anon_device` (`device_id`);',1,'','2022-01-01 00:00:00'), + ('add index anon_device.updated_at','CREATE INDEX `IDX_anon_device_updated_at` ON `anon_device` (`updated_at`);',1,'','2022-01-01 00:00:00'), + ('create signing_key table','CREATE TABLE IF NOT EXISTS `signing_key` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `key_id` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `private_key` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `added_at` DATETIME NOT NULL\n, `expires_at` DATETIME NULL\n, `alg` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add unique index signing_key.key_id','CREATE UNIQUE INDEX `UQE_signing_key_key_id` ON `signing_key` (`key_id`);',1,'','2022-01-01 00:00:00'), + ('set legacy alert migration status in kvstore','code migration',1,'','2022-01-01 00:00:00'), + ('migrate record of created folders during legacy migration to kvstore','code migration',1,'','2022-01-01 00:00:00'), + ('Add folder_uid for dashboard','alter table `dashboard` ADD COLUMN `folder_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Populate dashboard folder_uid column','code migration',1,'','2022-01-01 00:00:00'), + ('Add unique index for dashboard_org_id_folder_uid_title','code migration',1,'','2022-01-01 00:00:00'), + ('Delete unique index for dashboard_org_id_folder_id_title','DROP INDEX `UQE_dashboard_org_id_folder_id_title` ON `dashboard`',1,'','2022-01-01 00:00:00'), + ('Delete unique index for dashboard_org_id_folder_uid_title','code migration',1,'','2022-01-01 00:00:00'), + ('Add unique index for dashboard_org_id_folder_uid_title_is_folder','CREATE UNIQUE INDEX `UQE_dashboard_org_id_folder_uid_title_is_folder` ON `dashboard` (`org_id`,`folder_uid`,`title`,`is_folder`);',1,'','2022-01-01 00:00:00'), + ('Restore index for dashboard_org_id_folder_id_title','CREATE INDEX `IDX_dashboard_org_id_folder_id_title` ON `dashboard` (`org_id`,`folder_id`,`title`);',1,'','2022-01-01 00:00:00'), + ('Remove unique index for dashboard_org_id_folder_uid_title_is_folder','DROP INDEX `UQE_dashboard_org_id_folder_uid_title_is_folder` ON `dashboard`',1,'','2022-01-01 00:00:00'), + ('create sso_setting table','CREATE TABLE IF NOT EXISTS `sso_setting` (\n`id` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci PRIMARY KEY NOT NULL\n, `provider` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `settings` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n, `is_deleted` TINYINT(1) NOT NULL DEFAULT 0\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('update settings column to MEDIUMTEXT','ALTER TABLE sso_setting MODIFY COLUMN settings MEDIUMTEXT NOT NULL;',1,'','2022-01-01 00:00:00'), + ('copy kvstore migration status to each org','code migration',1,'','2022-01-01 00:00:00'), + ('add back entry for orgid=0 migrated status','code migration',1,'','2022-01-01 00:00:00'), + ('managed dashboard permissions annotation actions migration','code migration',1,'','2022-01-01 00:00:00'), + ('create cloud_migration table v1','CREATE TABLE IF NOT EXISTS `cloud_migration` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `auth_token` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `stack` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create cloud_migration_run table v1','CREATE TABLE IF NOT EXISTS `cloud_migration_run` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `cloud_migration_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `result` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n, `finished` DATETIME NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add stack_id column','alter table `cloud_migration` ADD COLUMN `stack_id` BIGINT(20) NOT NULL ',1,'','2022-01-01 00:00:00'), + ('add region_slug column','alter table `cloud_migration` ADD COLUMN `region_slug` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL ',1,'','2022-01-01 00:00:00'), + ('add cluster_slug column','alter table `cloud_migration` ADD COLUMN `cluster_slug` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL ',1,'','2022-01-01 00:00:00'), + ('add migration uid column','alter table `cloud_migration` ADD COLUMN `uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Update uid column values for migration','UPDATE cloud_migration SET uid=concat(\'u\',lpad(id,9,\'0\')) WHERE uid IS NULL;',1,'','2022-01-01 00:00:00'), + ('Add unique index migration_uid','CREATE UNIQUE INDEX `UQE_cloud_migration_uid` ON `cloud_migration` (`uid`);',1,'','2022-01-01 00:00:00'), + ('add migration run uid column','alter table `cloud_migration_run` ADD COLUMN `uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Update uid column values for migration run','UPDATE cloud_migration_run SET uid=concat(\'u\',lpad(id,9,\'0\')) WHERE uid IS NULL;',1,'','2022-01-01 00:00:00'), + ('Add unique index migration_run_uid','CREATE UNIQUE INDEX `UQE_cloud_migration_run_uid` ON `cloud_migration_run` (`uid`);',1,'','2022-01-01 00:00:00'), + ('Rename table cloud_migration to cloud_migration_session_tmp_qwerty - v1','ALTER TABLE `cloud_migration` RENAME TO `cloud_migration_session_tmp_qwerty`',1,'','2022-01-01 00:00:00'), + ('create cloud_migration_session v2','CREATE TABLE IF NOT EXISTS `cloud_migration_session` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `auth_token` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `slug` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `stack_id` BIGINT(20) NOT NULL\n, `region_slug` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `cluster_slug` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create index UQE_cloud_migration_session_uid - v2','CREATE UNIQUE INDEX `UQE_cloud_migration_session_uid` ON `cloud_migration_session` (`uid`);',1,'','2022-01-01 00:00:00'), + ('copy cloud_migration_session v1 to v2','INSERT INTO `cloud_migration_session` (`id`\n, `auth_token`\n, `stack_id`\n, `cluster_slug`\n, `updated`\n, `uid`\n, `slug`\n, `region_slug`\n, `created`)\nSELECT `id`\n, `auth_token`\n, `stack_id`\n, `cluster_slug`\n, `updated`\n, `uid`\n, `stack`\n, `region_slug`\n, `created`\nFROM `cloud_migration_session_tmp_qwerty`',1,'','2022-01-01 00:00:00'), + ('drop cloud_migration_session_tmp_qwerty','DROP TABLE IF EXISTS `cloud_migration_session_tmp_qwerty`',1,'','2022-01-01 00:00:00'), + ('Rename table cloud_migration_run to cloud_migration_snapshot_tmp_qwerty - v1','ALTER TABLE `cloud_migration_run` RENAME TO `cloud_migration_snapshot_tmp_qwerty`',1,'','2022-01-01 00:00:00'), + ('create cloud_migration_snapshot v2','CREATE TABLE IF NOT EXISTS `cloud_migration_snapshot` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `session_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `result` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n, `finished` DATETIME NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create index UQE_cloud_migration_snapshot_uid - v2','CREATE UNIQUE INDEX `UQE_cloud_migration_snapshot_uid` ON `cloud_migration_snapshot` (`uid`);',1,'','2022-01-01 00:00:00'), + ('copy cloud_migration_snapshot v1 to v2','INSERT INTO `cloud_migration_snapshot` (`created`\n, `updated`\n, `finished`\n, `id`\n, `uid`\n, `session_uid`\n, `result`)\nSELECT `created`\n, `updated`\n, `finished`\n, `id`\n, `uid`\n, `cloud_migration_uid`\n, `result`\nFROM `cloud_migration_snapshot_tmp_qwerty`',1,'','2022-01-01 00:00:00'), + ('drop cloud_migration_snapshot_tmp_qwerty','DROP TABLE IF EXISTS `cloud_migration_snapshot_tmp_qwerty`',1,'','2022-01-01 00:00:00'), + ('add snapshot upload_url column','alter table `cloud_migration_snapshot` ADD COLUMN `upload_url` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add snapshot status column','alter table `cloud_migration_snapshot` ADD COLUMN `status` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL ',1,'','2022-01-01 00:00:00'), + ('add snapshot local_directory column','alter table `cloud_migration_snapshot` ADD COLUMN `local_directory` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add snapshot gms_snapshot_uid column','alter table `cloud_migration_snapshot` ADD COLUMN `gms_snapshot_uid` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add snapshot encryption_key column','alter table `cloud_migration_snapshot` ADD COLUMN `encryption_key` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add snapshot error_string column','alter table `cloud_migration_snapshot` ADD COLUMN `error_string` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('create cloud_migration_resource table v1','CREATE TABLE IF NOT EXISTS `cloud_migration_resource` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `resource_type` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `resource_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `status` VARCHAR(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `error_string` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `snapshot_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('delete cloud_migration_snapshot.result column','ALTER TABLE cloud_migration_snapshot DROP COLUMN result',1,'','2022-01-01 00:00:00'), + ('add cloud_migration_resource.name column','alter table `cloud_migration_resource` ADD COLUMN `name` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add cloud_migration_resource.parent_name column','alter table `cloud_migration_resource` ADD COLUMN `parent_name` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add cloud_migration_session.org_id column','alter table `cloud_migration_session` ADD COLUMN `org_id` BIGINT(20) NOT NULL DEFAULT 1 ',1,'','2022-01-01 00:00:00'), + ('add cloud_migration_resource.error_code column','alter table `cloud_migration_resource` ADD COLUMN `error_code` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('increase resource_uid column length','ALTER TABLE cloud_migration_resource MODIFY resource_uid NVARCHAR(255);',1,'','2022-01-01 00:00:00'), + ('create cloud_migration_snapshot_partition table v1','CREATE TABLE IF NOT EXISTS `cloud_migration_snapshot_partition` (\n`snapshot_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `partition_number` INT NOT NULL\n, `resource_type` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `data` LONGBLOB NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add cloud_migration_snapshot_partition srp_unique index','CREATE UNIQUE INDEX `UQE_cloud_migration_snapshot_partition_srp_unique` ON `cloud_migration_snapshot_partition` (`snapshot_uid`,`resource_type`,`partition_number`);',1,'','2022-01-01 00:00:00'), + ('add resource_storage_type column to cloud_migration_snapshot table','alter table `cloud_migration_snapshot` ADD COLUMN `resource_storage_type` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add encryption_algo column to cloud_migration_snapshot table','alter table `cloud_migration_snapshot` ADD COLUMN `encryption_algo` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add metadata column to cloud_migration_snapshot table','alter table `cloud_migration_snapshot` ADD COLUMN `metadata` BLOB NULL ',1,'','2022-01-01 00:00:00'), + ('add public_key column to cloud_migration_snapshot table','alter table `cloud_migration_snapshot` ADD COLUMN `public_key` BLOB NULL ',1,'','2022-01-01 00:00:00'), + ('drop my_row_id and add primary key with columns snapshot_uid,resource_type,partition_number to table cloud_migration_snapshot_partition if my_row_id exists (auto-generated mysql column)','\n ALTER TABLE cloud_migration_snapshot_partition\n DROP PRIMARY KEY,\n DROP COLUMN my_row_id,\n DROP INDEX UQE_cloud_migration_snapshot_partition_srp_unique,\n ADD PRIMARY KEY (`snapshot_uid`,`resource_type`,`partition_number`);\n ',1,'','2022-01-01 00:00:00'), + ('drop unique index UQE_cloud_migration_snapshot_partition_srp_unique from cloud_migration_snapshot_partition table if it exists (mysql)','ALTER TABLE cloud_migration_snapshot_partition DROP INDEX UQE_cloud_migration_snapshot_partition_srp_unique',1,'','2022-01-01 00:00:00'), + ('add primary key with columns snapshot_uid,resource_type,partition_number to table cloud_migration_snapshot_partition if it doesn\'t exist (mysql)','ALTER TABLE cloud_migration_snapshot_partition ADD PRIMARY KEY (`snapshot_uid`,`resource_type`,`partition_number`)',1,'','2022-01-01 00:00:00'), + ('add primary key with columns snapshot_uid,resource_type,partition_number to table cloud_migration_snapshot_partition (postgres and sqlite)','',1,'','2022-01-01 00:00:00'), + ('alter kv_store.value to longtext','ALTER TABLE kv_store MODIFY value LONGTEXT NOT NULL;',1,'','2022-01-01 00:00:00'), + ('add notification_settings column to alert_rule table','alter table `alert_rule` ADD COLUMN `notification_settings` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add notification_settings column to alert_rule_version table','alter table `alert_rule_version` ADD COLUMN `notification_settings` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('removing scope from alert.instances:read action migration','code migration',1,'','2022-01-01 00:00:00'), + ('managed folder permissions alerting silences actions migration','code migration',1,'','2022-01-01 00:00:00'), + ('add record column to alert_rule table','alter table `alert_rule` ADD COLUMN `record` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add record column to alert_rule_version table','alter table `alert_rule_version` ADD COLUMN `record` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add resolved_at column to alert_instance table','alter table `alert_instance` ADD COLUMN `resolved_at` BIGINT(20) NULL ',1,'','2022-01-01 00:00:00'), + ('add last_sent_at column to alert_instance table','alter table `alert_instance` ADD COLUMN `last_sent_at` BIGINT(20) NULL ',1,'','2022-01-01 00:00:00'), + ('Add scope to alert.notifications.receivers:read and alert.notifications.receivers.secrets:read','code migration',1,'','2022-01-01 00:00:00'), + ('add metadata column to alert_rule table','alter table `alert_rule` ADD COLUMN `metadata` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add metadata column to alert_rule_version table','alter table `alert_rule_version` ADD COLUMN `metadata` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('delete orphaned service account permissions','code migration',1,'','2022-01-01 00:00:00'), + ('adding action set permissions','code migration',1,'','2022-01-01 00:00:00'), + ('create user_external_session table','CREATE TABLE IF NOT EXISTS `user_external_session` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `user_auth_id` BIGINT(20) NOT NULL\n, `user_id` BIGINT(20) NOT NULL\n, `auth_module` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `access_token` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `id_token` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `refresh_token` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `session_id` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `session_id_hash` CHAR(44) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `name_id` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `name_id_hash` CHAR(44) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `expires_at` DATETIME NULL\n, `created_at` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('increase name_id column length to 1024','ALTER TABLE user_external_session MODIFY name_id NVARCHAR(1024);',1,'','2022-01-01 00:00:00'), + ('increase session_id column length to 1024','ALTER TABLE user_external_session MODIFY session_id NVARCHAR(1024);',1,'','2022-01-01 00:00:00'), + ('remove scope from alert.notifications.receivers:create','code migration',1,'','2022-01-01 00:00:00'), + ('add created_by column to alert_rule_version table','alter table `alert_rule_version` ADD COLUMN `created_by` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add updated_by column to alert_rule table','alter table `alert_rule` ADD COLUMN `updated_by` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add alert_rule_state table','CREATE TABLE IF NOT EXISTS `alert_rule_state` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `rule_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `data` LONGBLOB NOT NULL\n, `updated_at` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index to alert_rule_state on org_id and rule_uid columns','CREATE UNIQUE INDEX `UQE_alert_rule_state_org_id_rule_uid` ON `alert_rule_state` (`org_id`,`rule_uid`);',1,'','2022-01-01 00:00:00'), + ('add guid column to alert_rule table','alter table `alert_rule` ADD COLUMN `guid` VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT \'\' ',1,'','2022-01-01 00:00:00'), + ('add rule_guid column to alert_rule_version table','alter table `alert_rule_version` ADD COLUMN `rule_guid` VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT \'\' ',1,'','2022-01-01 00:00:00'), + ('cleanup alert_rule_version table','code migration',1,'','2022-01-01 00:00:00'), + ('populate rule guid in alert rule table','code migration',1,'','2022-01-01 00:00:00'), + ('drop index in alert_rule_version table on rule_org_id, rule_uid and version columns','DROP INDEX `UQE_alert_rule_version_rule_org_id_rule_uid_version` ON `alert_rule_version`',1,'','2022-01-01 00:00:00'), + ('add index in alert_rule_version table on rule_org_id, rule_uid, rule_guid and version columns','CREATE UNIQUE INDEX `UQE_alert_rule_version_rule_org_id_rule_uid_rule_guid_version` ON `alert_rule_version` (`rule_org_id`,`rule_uid`,`rule_guid`,`version`);',1,'','2022-01-01 00:00:00'), + ('add index in alert_rule_version table on rule_guid and version columns','CREATE UNIQUE INDEX `UQE_alert_rule_version_rule_guid_version` ON `alert_rule_version` (`rule_guid`,`version`);',1,'','2022-01-01 00:00:00'), + ('add index in alert_rule table on guid columns','CREATE UNIQUE INDEX `UQE_alert_rule_guid` ON `alert_rule` (`guid`);',1,'','2022-01-01 00:00:00'), + ('add keep_firing_for column to alert_rule','alter table `alert_rule` ADD COLUMN `keep_firing_for` BIGINT(20) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('add keep_firing_for column to alert_rule_version','alter table `alert_rule_version` ADD COLUMN `keep_firing_for` BIGINT(20) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('add missing_series_evals_to_resolve column to alert_rule','alter table `alert_rule` ADD COLUMN `missing_series_evals_to_resolve` SMALLINT NULL ',1,'','2022-01-01 00:00:00'), + ('add missing_series_evals_to_resolve column to alert_rule_version','alter table `alert_rule_version` ADD COLUMN `missing_series_evals_to_resolve` SMALLINT NULL ',1,'','2022-01-01 00:00:00'), + ('remove the datasources:drilldown action','code migration',1,'','2022-01-01 00:00:00'), + ('remove title in folder unique index','DROP INDEX `UQE_alert_rule_org_id_namespace_uid_title` ON `alert_rule`',1,'','2022-01-01 00:00:00'), + ('add fired_at column to alert_instance table','alter table `alert_instance` ADD COLUMN `fired_at` BIGINT(20) NULL ',1,'','2022-01-01 00:00:00'), + ('ensure rule_group column is case sensitive in returned results','ALTER TABLE alert_rule MODIFY rule_group VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_cs NOT NULL;',1,'','2022-01-01 00:00:00'), + ('expand created_by in alert_rule_version table','ALTER TABLE `alert_rule_version` MODIFY created_by VARCHAR(190);',1,'','2022-01-01 00:00:00'), + ('expand updated_by in alert_rule table','ALTER TABLE `alert_rule` MODIFY updated_by VARCHAR(190);',1,'','2022-01-01 00:00:00'), + ('add index in alert_rule on org_id, namespace_uid, rule_group and rule_group_idx columns','CREATE INDEX `IDX_alert_rule_org_id_namespace_uid_rule_group_rule_group_idx` ON `alert_rule` (`org_id`,`namespace_uid`,`rule_group`,`rule_group_idx`);',1,'','2022-01-01 00:00:00'), + ('add annotations column to alert_instance table','alter table `alert_instance` ADD COLUMN `annotations` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('ensure namespace_uid column sorts the same way as golang','ALTER TABLE alert_rule MODIFY namespace_uid VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL;',1,'','2022-01-01 00:00:00'), + ('ensure rule_group column sorts the same way as golang','ALTER TABLE alert_rule MODIFY rule_group VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL;',1,'','2022-01-01 00:00:00'), + ('add \'alert.notifications.receivers.protected:write\' to receiver admins','code migration',1,'','2022-01-01 00:00:00'), + ('create data_source_usage_by_day table','CREATE TABLE IF NOT EXISTS `data_source_usage_by_day` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `data_source_id` BIGINT(20) NOT NULL\n, `day` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `queries` BIGINT(20) NOT NULL\n, `errors` BIGINT(20) NOT NULL\n, `load_duration_ms` BIGINT(20) NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create data_source_usage_by_day(data_source_id) index','CREATE INDEX `IDX_data_source_usage_by_day_data_source_id` ON `data_source_usage_by_day` (`data_source_id`);',1,'','2022-01-01 00:00:00'), + ('create data_source_usage_by_day(data_source_id, day) unique index','CREATE UNIQUE INDEX `UQE_data_source_usage_by_day_data_source_id_day` ON `data_source_usage_by_day` (`data_source_id`,`day`);',1,'','2022-01-01 00:00:00'), + ('create dashboard_usage_by_day table','CREATE TABLE IF NOT EXISTS `dashboard_usage_by_day` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `dashboard_id` BIGINT(20) NOT NULL\n, `day` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `views` BIGINT(20) NOT NULL\n, `queries` BIGINT(20) NOT NULL\n, `errors` BIGINT(20) NOT NULL\n, `load_duration` FLOAT NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create dashboard_usage_sums table','CREATE TABLE IF NOT EXISTS `dashboard_usage_sums` (\n`dashboard_id` BIGINT(20) PRIMARY KEY NOT NULL\n, `updated` DATETIME NOT NULL\n, `views_last_1_days` BIGINT(20) NOT NULL\n, `views_last_7_days` BIGINT(20) NOT NULL\n, `views_last_30_days` BIGINT(20) NOT NULL\n, `views_total` BIGINT(20) NOT NULL\n, `queries_last_1_days` BIGINT(20) NOT NULL\n, `queries_last_7_days` BIGINT(20) NOT NULL\n, `queries_last_30_days` BIGINT(20) NOT NULL\n, `queries_total` BIGINT(20) NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create dashboard_usage_by_day(dashboard_id) index','CREATE INDEX `IDX_dashboard_usage_by_day_dashboard_id` ON `dashboard_usage_by_day` (`dashboard_id`);',1,'','2022-01-01 00:00:00'), + ('create dashboard_usage_by_day(dashboard_id, day) index','CREATE UNIQUE INDEX `UQE_dashboard_usage_by_day_dashboard_id_day` ON `dashboard_usage_by_day` (`dashboard_id`,`day`);',1,'','2022-01-01 00:00:00'), + ('add column errors_last_1_days to dashboard_usage_sums','alter table `dashboard_usage_sums` ADD COLUMN `errors_last_1_days` BIGINT(20) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('add column errors_last_7_days to dashboard_usage_sums','alter table `dashboard_usage_sums` ADD COLUMN `errors_last_7_days` BIGINT(20) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('add column errors_last_30_days to dashboard_usage_sums','alter table `dashboard_usage_sums` ADD COLUMN `errors_last_30_days` BIGINT(20) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('add column errors_total to dashboard_usage_sums','alter table `dashboard_usage_sums` ADD COLUMN `errors_total` BIGINT(20) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('create dashboard_public_usage_by_day table','CREATE TABLE IF NOT EXISTS `dashboard_public_usage_by_day` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `public_dashboard_uid` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `day` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `views` BIGINT(20) NOT NULL\n, `queries` BIGINT(20) NOT NULL\n, `errors` BIGINT(20) NOT NULL\n, `load_duration` FLOAT NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add column cached_queries to dashboard_usage_by_day table','alter table `dashboard_usage_by_day` ADD COLUMN `cached_queries` BIGINT(20) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('add column cached_queries to dashboard_public_usage_by_day table','alter table `dashboard_public_usage_by_day` ADD COLUMN `cached_queries` BIGINT(20) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('add column dashboard_uid to dashboard_usage_sums','alter table `dashboard_usage_sums` ADD COLUMN `dashboard_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add column org_id to dashboard_usage_sums','alter table `dashboard_usage_sums` ADD COLUMN `org_id` BIGINT(20) NULL DEFAULT 1 ',1,'','2022-01-01 00:00:00'), + ('add column dashboard_uid to dashboard_usage_by_day','alter table `dashboard_usage_by_day` ADD COLUMN `dashboard_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add column org_id to dashboard_usage_by_day','alter table `dashboard_usage_by_day` ADD COLUMN `org_id` BIGINT(20) NULL DEFAULT 1 ',1,'','2022-01-01 00:00:00'), + ('create dashboard_usage_by_day(dashboard_uid, org_id, day) unique index','CREATE UNIQUE INDEX `UQE_dashboard_usage_by_day_dashboard_uid_org_id_day` ON `dashboard_usage_by_day` (`dashboard_uid`,`org_id`,`day`);',1,'','2022-01-01 00:00:00'), + ('Add missing dashboard_uid and org_id to dashboard_usage_by_day and dashboard_usage_sums','code migration',1,'','2022-01-01 00:00:00'), + ('Add dashboard_usage_sums(org_id, dashboard_uid) index','CREATE UNIQUE INDEX `UQE_dashboard_usage_sums_org_id_dashboard_uid` ON `dashboard_usage_sums` (`org_id`,`dashboard_uid`);',1,'','2022-01-01 00:00:00'), + ('create user_dashboard_views table','CREATE TABLE IF NOT EXISTS `user_dashboard_views` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `user_id` BIGINT(20) NOT NULL\n, `dashboard_id` BIGINT(20) NOT NULL\n, `viewed` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index user_dashboard_views.user_id','CREATE INDEX `IDX_user_dashboard_views_user_id` ON `user_dashboard_views` (`user_id`);',1,'','2022-01-01 00:00:00'), + ('add index user_dashboard_views.dashboard_id','CREATE INDEX `IDX_user_dashboard_views_dashboard_id` ON `user_dashboard_views` (`dashboard_id`);',1,'','2022-01-01 00:00:00'), + ('add unique index user_dashboard_views_user_id_dashboard_id','CREATE UNIQUE INDEX `UQE_user_dashboard_views_user_id_dashboard_id` ON `user_dashboard_views` (`user_id`,`dashboard_id`);',1,'','2022-01-01 00:00:00'), + ('add org_id column to user_dashboard_views','alter table `user_dashboard_views` ADD COLUMN `org_id` BIGINT(20) NULL ',1,'','2022-01-01 00:00:00'), + ('add dashboard_uid column to user_dashboard_views','alter table `user_dashboard_views` ADD COLUMN `dashboard_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('add unique index user_dashboard_views_org_id_dashboard_uid','CREATE INDEX `IDX_user_dashboard_views_org_id_dashboard_uid` ON `user_dashboard_views` (`org_id`,`dashboard_uid`);',1,'','2022-01-01 00:00:00'), + ('add unique index user_dashboard_views_org_id_user_id_dashboard_uid','CREATE UNIQUE INDEX `UQE_user_dashboard_views_user_id_org_id_dashboard_uid` ON `user_dashboard_views` (`user_id`,`org_id`,`dashboard_uid`);',1,'','2022-01-01 00:00:00'), + ('populate user_dashboard_views.dashboard_uid and user_dashboard_views.org_id from dashboard table','views migration',1,'','2022-01-01 00:00:00'), + ('delete views where dashboard does not exist','DELETE FROM user_dashboard_views WHERE (dashboard_uid IS NULL OR org_id IS NULL) AND NOT EXISTS (SELECT 1 FROM dashboard WHERE dashboard.id = user_dashboard_views.dashboard_id)',1,'','2022-01-01 00:00:00'), + ('create user_stats table','CREATE TABLE IF NOT EXISTS `user_stats` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `user_id` BIGINT(20) NOT NULL\n, `billing_role` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add unique index user_stats(user_id)','CREATE UNIQUE INDEX `UQE_user_stats_user_id` ON `user_stats` (`user_id`);',1,'','2022-01-01 00:00:00'), + ('create data_source_cache table','CREATE TABLE IF NOT EXISTS `data_source_cache` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `data_source_id` BIGINT(20) NOT NULL\n, `enabled` TINYINT(1) NOT NULL\n, `ttl_ms` BIGINT(20) NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index data_source_cache.data_source_id','CREATE INDEX `IDX_data_source_cache_data_source_id` ON `data_source_cache` (`data_source_id`);',1,'','2022-01-01 00:00:00'), + ('add use_default_ttl column','alter table `data_source_cache` ADD COLUMN `use_default_ttl` TINYINT(1) NOT NULL DEFAULT 1 ',1,'','2022-01-01 00:00:00'), + ('add data_source_cache.data_source_uid column','alter table `data_source_cache` ADD COLUMN `data_source_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('remove abandoned data_source_cache records','DELETE FROM data_source_cache WHERE NOT EXISTS (SELECT 1 FROM data_source WHERE id = data_source_cache.data_source_id);',1,'','2022-01-01 00:00:00'), + ('update data_source_cache.data_source_uid value','UPDATE data_source_cache JOIN data_source ON data_source_cache.data_source_id = data_source.id SET data_source_cache.data_source_uid = data_source.uid;',1,'','2022-01-01 00:00:00'), + ('add index data_source_cache.data_source_uid','CREATE INDEX `IDX_data_source_cache_data_source_uid` ON `data_source_cache` (`data_source_uid`);',1,'','2022-01-01 00:00:00'), + ('add data_source_cache.ttl_resources_ms column','alter table `data_source_cache` ADD COLUMN `ttl_resources_ms` BIGINT(20) NOT NULL DEFAULT 300000 ',1,'','2022-01-01 00:00:00'), + ('update data_source_cache.ttl_resources_ms to have the same value as ttl_ms','UPDATE data_source_cache SET ttl_resources_ms = ttl_ms;',1,'','2022-01-01 00:00:00'), + ('create data_source_acl table','CREATE TABLE IF NOT EXISTS `data_source_acl` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `data_source_id` BIGINT(20) NOT NULL\n, `team_id` BIGINT(20) NOT NULL\n, `user_id` BIGINT(20) NOT NULL\n, `permission` SMALLINT NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index data_source_acl.data_source_id','CREATE INDEX `IDX_data_source_acl_data_source_id` ON `data_source_acl` (`data_source_id`);',1,'','2022-01-01 00:00:00'), + ('add unique index datasource_acl.unique','CREATE UNIQUE INDEX `UQE_data_source_acl_data_source_id_team_id_user_id` ON `data_source_acl` (`data_source_id`,`team_id`,`user_id`);',1,'','2022-01-01 00:00:00'), + ('create license_token table','CREATE TABLE IF NOT EXISTS `license_token` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `token` LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('drop recorded_queries table v14','DROP TABLE IF EXISTS `recorded_queries`',1,'','2022-01-01 00:00:00'), + ('drop recording_rules table v14','DROP TABLE IF EXISTS `recording_rules`',1,'','2022-01-01 00:00:00'), + ('create recording_rules table v14','CREATE TABLE IF NOT EXISTS `recording_rules` (\n`id` VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `target_ref_id` VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `name` VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `description` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `interval` BIGINT(20) NOT NULL\n, `range` BIGINT(20) NOT NULL\n, `active` TINYINT(1) NOT NULL DEFAULT 0\n, `count` TINYINT(1) NOT NULL DEFAULT 0\n, `queries` BLOB NOT NULL\n, `created_at` DATETIME NOT NULL\n, PRIMARY KEY ( `id`,`target_ref_id` )) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create remote_write_targets table v1','CREATE TABLE IF NOT EXISTS `remote_write_targets` (\n`id` VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `data_source_uid` VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `write_path` VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `org_id` INT NOT NULL\n, PRIMARY KEY ( `id`,`data_source_uid`,`write_path` )) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('Add prom_name to recording_rules table','alter table `recording_rules` ADD COLUMN `prom_name` VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('ensure remote_write_targets table','CREATE TABLE IF NOT EXISTS `remote_write_targets` (\n`id` VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `data_source_uid` VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `write_path` VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `org_id` INT NOT NULL\n, PRIMARY KEY ( `id`,`data_source_uid`,`write_path` )) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create report config table v1','CREATE TABLE IF NOT EXISTS `report` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `user_id` BIGINT(20) NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `dashboard_id` BIGINT(20) NOT NULL\n, `name` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `recipients` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `reply_to` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `message` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `schedule_frequency` VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `schedule_day` VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `schedule_hour` BIGINT(20) NOT NULL\n, `schedule_minute` BIGINT(20) NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('Add index report.user_id','CREATE INDEX `IDX_report_user_id` ON `report` (`user_id`);',1,'','2022-01-01 00:00:00'), + ('add index to dashboard_id','CREATE INDEX `IDX_report_dashboard_id` ON `report` (`dashboard_id`);',1,'','2022-01-01 00:00:00'), + ('add index to org_id','CREATE INDEX `IDX_report_org_id` ON `report` (`org_id`);',1,'','2022-01-01 00:00:00'), + ('Add timezone to the report','alter table `report` ADD COLUMN `schedule_timezone` VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT \'Europe/Stockholm\' ',1,'','2022-01-01 00:00:00'), + ('Add time_from to the report','alter table `report` ADD COLUMN `time_from` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Add time_to to the report','alter table `report` ADD COLUMN `time_to` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Add PDF landscape option to the report','alter table `report` ADD COLUMN `pdf_landscape` TINYINT(1) NULL ',1,'','2022-01-01 00:00:00'), + ('Add monthly day scheduling option to the report','alter table `report` ADD COLUMN `schedule_day_of_month` VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Add PDF layout option to the report','alter table `report` ADD COLUMN `pdf_layout` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Add PDF orientation option to the report','alter table `report` ADD COLUMN `pdf_orientation` VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Update report pdf_orientation from pdf_landscape','UPDATE report SET pdf_orientation = \'landscape\' WHERE pdf_landscape = 1',1,'','2022-01-01 00:00:00'), + ('create report settings table','CREATE TABLE IF NOT EXISTS `report_settings` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `user_id` BIGINT(20) NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `branding_report_logo_url` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `branding_email_logo_url` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `branding_email_footer_link` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `branding_email_footer_text` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `branding_email_footer_mode` VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('Add dashboard_uid field to the report','alter table `report` ADD COLUMN `dashboard_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Add template_vars field to the report','alter table `report` ADD COLUMN `template_vars` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Add option to include dashboard url in the report','alter table `report` ADD COLUMN `enable_dashboard_url` TINYINT(1) NULL ',1,'','2022-01-01 00:00:00'), + ('Add state field to the report','alter table `report` ADD COLUMN `state` VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Add option to add CSV files to the report','alter table `report` ADD COLUMN `enable_csv` TINYINT(1) NULL ',1,'','2022-01-01 00:00:00'), + ('Add scheduling start date','alter table `report` ADD COLUMN `schedule_start` INT NULL ',1,'','2022-01-01 00:00:00'), + ('Add missing schedule_start date for old reports','code migration',1,'','2022-01-01 00:00:00'), + ('Add scheduling end date','alter table `report` ADD COLUMN `schedule_end` INT NULL ',1,'','2022-01-01 00:00:00'), + ('Add schedulinng custom interval frequency','alter table `report` ADD COLUMN `schedule_interval_frequency` VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Add scheduling custom interval amount','alter table `report` ADD COLUMN `schedule_interval_amount` BIGINT(20) NULL ',1,'','2022-01-01 00:00:00'), + ('Add workdays only flag to report','alter table `report` ADD COLUMN `schedule_workdays_only` TINYINT(1) NULL ',1,'','2022-01-01 00:00:00'), + ('create report dashboards table','CREATE TABLE IF NOT EXISTS `report_dashboards` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `report_id` BIGINT(20) NOT NULL\n, `dashboard_uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT \'\'\n, `report_variables` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `time_to` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n, `time_from` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('Add index report_dashboards.report_id','CREATE INDEX `IDX_report_dashboards_report_id` ON `report_dashboards` (`report_id`);',1,'','2022-01-01 00:00:00'), + ('Migrate report fields into report_dashboards','INSERT INTO report_dashboards (report_id, dashboard_uid, report_variables, time_to, time_from) SELECT id, CASE WHEN dashboard_uid IS NULL THEN \'\' ELSE dashboard_uid END, template_vars, time_to, time_from FROM report',1,'','2022-01-01 00:00:00'), + ('Add formats option to the report','alter table `report` ADD COLUMN `formats` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT \'[\"pdf\"]\' ',1,'','2022-01-01 00:00:00'), + ('Migrate reports with csv enabled','UPDATE report SET formats=\'[\"pdf\",\"csv\"]\' WHERE enable_csv IS TRUE;',1,'','2022-01-01 00:00:00'), + ('Migrate ancient reports','code migration',1,'','2022-01-01 00:00:00'), + ('Add created column in report_dashboards','alter table `report_dashboards` ADD COLUMN `created` DATETIME NULL ',1,'','2022-01-01 00:00:00'), + ('Add scale_factor to the report','alter table `report` ADD COLUMN `scale_factor` TINYINT NOT NULL DEFAULT 2 ',1,'','2022-01-01 00:00:00'), + ('Alter scale_factor from TINYINT to SMALLINT','ALTER TABLE report MODIFY scale_factor SMALLINT NOT NULL DEFAULT 2',1,'','2022-01-01 00:00:00'), + ('Add uid column to report','alter table `report` ADD COLUMN `uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Add unique index reports_org_id_uid','CREATE UNIQUE INDEX `UQE_report_org_id_uid` ON `report` (`org_id`,`uid`);',1,'','2022-01-01 00:00:00'), + ('Add pdf show template variable values to the report','alter table `report` ADD COLUMN `pdf_show_template_variables` TINYINT(1) NOT NULL DEFAULT 0 ',1,'','2022-01-01 00:00:00'), + ('Add pdf combine in one file','alter table `report` ADD COLUMN `pdf_combine_one_file` TINYINT(1) NOT NULL DEFAULT 1 ',1,'','2022-01-01 00:00:00'), + ('Add pdf theme to report settings table','alter table `report_settings` ADD COLUMN `pdf_theme` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT \'light\' ',1,'','2022-01-01 00:00:00'), + ('Add email subject to the report','alter table `report` ADD COLUMN `subject` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Populate email subject with report name','UPDATE report SET subject = name WHERE subject IS NULL',1,'','2022-01-01 00:00:00'), + ('Add embedded image theme to report settings table','alter table `report_settings` ADD COLUMN `embedded_image_theme` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT \'dark\' ',1,'','2022-01-01 00:00:00'), + ('create team group table','CREATE TABLE IF NOT EXISTS `team_group` (\n`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT NOT NULL\n, `org_id` BIGINT(20) NOT NULL\n, `team_id` BIGINT(20) NOT NULL\n, `group_id` VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created` DATETIME NOT NULL\n, `updated` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add index team_group.org_id','CREATE INDEX `IDX_team_group_org_id` ON `team_group` (`org_id`);',1,'','2022-01-01 00:00:00'), + ('add unique index team_group.org_id_team_id_group_id','CREATE UNIQUE INDEX `UQE_team_group_org_id_team_id_group_id` ON `team_group` (`org_id`,`team_id`,`group_id`);',1,'','2022-01-01 00:00:00'), + ('add index team_group.group_id','CREATE INDEX `IDX_team_group_group_id` ON `team_group` (`group_id`);',1,'','2022-01-01 00:00:00'), + ('Add column uid in team_group','alter table `team_group` ADD COLUMN `uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('Update uid column values in team_group','UPDATE team_group SET uid=concat(\'tg\',lpad(id,9,\'0\')) WHERE uid IS NULL OR uid = \'\';',1,'','2022-01-01 00:00:00'), + ('Add unique index team_group_uid','CREATE UNIQUE INDEX `UQE_team_group_uid` ON `team_group` (`uid`);',1,'','2022-01-01 00:00:00'), + ('create settings table','CREATE TABLE IF NOT EXISTS `setting` (\n`section` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `key` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `value` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add unique index settings.section_key','CREATE UNIQUE INDEX `UQE_setting_section_key` ON `setting` (`section`,`key`);',1,'','2022-01-01 00:00:00'), + ('add setting.encrypted_value','alter table `setting` ADD COLUMN `encrypted_value` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL ',1,'','2022-01-01 00:00:00'), + ('drop my_row_id and add primary key to setting table if my_row_id exists (auto-generated mysql column)','\n ALTER TABLE setting\n DROP PRIMARY KEY,\n DROP COLUMN my_row_id,\n DROP INDEX UQE_setting_section_key,\n ADD PRIMARY KEY (section, `key`);\n ',1,'','2022-01-01 00:00:00'), + ('drop setting_section unique index from setting table if it exists (mysql)','ALTER TABLE setting DROP INDEX UQE_setting_section_key',1,'','2022-01-01 00:00:00'), + ('add primary key to setting table if it doesn\'t exist (mysql)','ALTER TABLE setting ADD PRIMARY KEY (section, `key`);',1,'','2022-01-01 00:00:00'), + ('add primary key to setting table (postgres and sqlite)','',1,'','2022-01-01 00:00:00'), + ('migrate role names','code migration',1,'','2022-01-01 00:00:00'), + ('rename orgs roles','code migration',1,'','2022-01-01 00:00:00'), + ('remove duplicated org role','code migration',1,'','2022-01-01 00:00:00'), + ('migrate alerting role names','code migration',1,'','2022-01-01 00:00:00'), + ('data source permissions','code migration',1,'','2022-01-01 00:00:00'), + ('data source uid permissions','code migration',1,'','2022-01-01 00:00:00'), + ('rename permissions:delegate scope','UPDATE permission SET scope = \'permissions:type:delegate\' WHERE scope = \'permissions:delegate\'',1,'','2022-01-01 00:00:00'), + ('remove invalid managed permissions','code migration',1,'','2022-01-01 00:00:00'), + ('builtin role migration','code migration',1,'','2022-01-01 00:00:00'), + ('seed permissions migration','code migration',1,'','2022-01-01 00:00:00'), + ('managed permissions migration enterprise','code migration',1,'','2022-01-01 00:00:00'), + ('create table dashboard_public_email_share','CREATE TABLE IF NOT EXISTS `dashboard_public_email_share` (\n`uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci PRIMARY KEY NOT NULL\n, `public_dashboard_uid` VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `recipient` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `type` VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT \'email\'\n, `created_at` DATETIME NOT NULL\n, `updated_at` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create table dashboard_public_magic_link','CREATE TABLE IF NOT EXISTS `dashboard_public_magic_link` (\n`uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci PRIMARY KEY NOT NULL\n, `token_uuid` VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `public_dashboard_uid` VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `email` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created_at` DATETIME NOT NULL\n, `updated_at` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('create table dashboard_public_session','CREATE TABLE IF NOT EXISTS `dashboard_public_session` (\n`uid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci PRIMARY KEY NOT NULL\n, `cookie_uuid` VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `public_dashboard_uid` VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `email` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL\n, `created_at` DATETIME NOT NULL\n, `updated_at` DATETIME NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;',1,'','2022-01-01 00:00:00'), + ('add last_seen_at column','alter table `dashboard_public_session` ADD COLUMN `last_seen_at` DATETIME NULL ',1,'','2022-01-01 00:00:00'); +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + From f970cbb42b3d488985868ae214219f84727f1062 Mon Sep 17 00:00:00 2001 From: Ihor Yeromin Date: Thu, 18 Dec 2025 10:25:53 +0100 Subject: [PATCH 008/229] Transformations: Gray out inapplicable transformation cards (#115512) * fix(transformation): gray out transformation card on transformation tab * fix(transformations): make data prop required in EmptyTransformationsMessage This ensures TypeScript enforces that all call sites pass the data prop, which is required for graying out inapplicable transformation cards. - Changed data prop from optional to required in EmptyTransformationsProps - Fixed TransformationsEditor.tsx to pass data (was missing in legacy code) - Updated tests to pass the required data prop --------- Co-authored-by: Sam Jewell --- .../PanelDataPane/EmptyTransformationsMessage.test.tsx | 9 +++++++-- .../PanelDataPane/EmptyTransformationsMessage.tsx | 4 +++- .../PanelDataPane/PanelDataTransformationsTab.tsx | 1 + .../TransformationsEditor/TransformationsEditor.tsx | 3 ++- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.test.tsx index 7f7af75146b..c974710b566 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.test.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.test.tsx @@ -56,6 +56,7 @@ describe('EmptyTransformationsMessage', () => { onShowPicker={onShowPicker} onGoToQueries={onGoToQueries} onAddTransformation={onAddTransformation} + data={[]} /> ); @@ -75,6 +76,7 @@ describe('EmptyTransformationsMessage', () => { onShowPicker={onShowPicker} onGoToQueries={onGoToQueries} onAddTransformation={onAddTransformation} + data={[]} /> ); @@ -92,6 +94,7 @@ describe('EmptyTransformationsMessage', () => { onShowPicker={onShowPicker} onGoToQueries={onGoToQueries} onAddTransformation={onAddTransformation} + data={[]} /> ); @@ -103,13 +106,15 @@ describe('EmptyTransformationsMessage', () => { }); it('should not show SQL transformation card when onGoToQueries is not provided', () => { - render(); + render( + + ); expect(screen.queryByText('Transform with SQL')).not.toBeInTheDocument(); }); it('should not show transformation cards grid when neither onGoToQueries nor onAddTransformation are provided', () => { - render(); + render(); expect(screen.queryByText('Transform with SQL')).not.toBeInTheDocument(); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.tsx index 80680aa4e1c..eab5f3e9c58 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; -import { DataTransformerID, standardTransformersRegistry, TransformerRegistryItem } from '@grafana/data'; +import { DataFrame, DataTransformerID, standardTransformersRegistry, TransformerRegistryItem } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { t, Trans } from '@grafana/i18n'; import { reportInteraction } from '@grafana/runtime'; @@ -16,6 +16,7 @@ interface EmptyTransformationsProps { onShowPicker: () => void; onGoToQueries?: () => void; onAddTransformation?: (transformationId: string) => void; + data: DataFrame[]; } const TRANSFORMATION_IDS = [ @@ -121,6 +122,7 @@ export function NewEmptyTransformationsMessage(props: EmptyTransformationsProps) showIllustrations={true} showPluginState={false} showTags={false} + data={props.data} /> ))} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx index af937d150c1..425eb792109 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx @@ -152,6 +152,7 @@ export function PanelDataTransformationsTabRendered({ model }: SceneComponentPro onShowPicker={openDrawer} onGoToQueries={onGoToQueries} onAddTransformation={onAddTransformation} + data={sourceData.data.series} /> {transformationsDrawer} diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx index 1593c6162b7..9bdcefdcab2 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx @@ -256,7 +256,8 @@ class UnThemedTransformationsEditor extends React.PureComponent { this.setState({ showPicker: true }); }} - > + data={this.state.data.series} + /> ); }; From e5b0353c417ef3837156a94d1e8a2258a6185895 Mon Sep 17 00:00:00 2001 From: Gabriel MABILLE Date: Thu, 18 Dec 2025 10:29:50 +0100 Subject: [PATCH 009/229] `grafana-iam`: Use an API Installer interface (#115310) * `grafana-iam`: Add basic roles to the apis * Fix validation * chore: trigger CI * Leave the hooks intact for now, moving them later * Remove Role mention from the interface * Refactor to use a NoopRest backend and Deny access --- pkg/registry/apis/iam/api_installer.go | 79 +++++++++++++++++++ pkg/registry/apis/iam/authorizer.go | 8 +- pkg/registry/apis/iam/models.go | 2 +- pkg/registry/apis/iam/noopstorage/rest.go | 74 ++++++++++++++++++ pkg/registry/apis/iam/register.go | 40 +++++----- pkg/registry/apis/iam/role_hooks.go | 71 +++++++++-------- pkg/registry/apis/iam/role_hooks_test.go | 92 +++++++++++------------ pkg/registry/apis/wireset.go | 2 +- pkg/server/wire_gen.go | 6 +- 9 files changed, 275 insertions(+), 99 deletions(-) create mode 100644 pkg/registry/apis/iam/api_installer.go create mode 100644 pkg/registry/apis/iam/noopstorage/rest.go diff --git a/pkg/registry/apis/iam/api_installer.go b/pkg/registry/apis/iam/api_installer.go new file mode 100644 index 00000000000..1d456dd3777 --- /dev/null +++ b/pkg/registry/apis/iam/api_installer.go @@ -0,0 +1,79 @@ +package iam + +import ( + "context" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/authorization/authorizer" + "k8s.io/apiserver/pkg/registry/rest" + "k8s.io/apiserver/pkg/server" + + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/registry/apis/iam/noopstorage" + "github.com/grafana/grafana/pkg/services/apiserver/builder" +) + +// ApiInstaller is a generic interface for installing API resources. +// This allows different implementations (OSS noop, Enterprise with features) +// to provide their own resource registration and validation logic. +type ApiInstaller[T runtime.Object] interface { + // GetAuthorizer returns the authorizer for the API group. + GetAuthorizer() authorizer.Authorizer + + // RegisterStorage registers storage and hooks with the API group. + RegisterStorage(apiGroupInfo *server.APIGroupInfo, opts *builder.APIGroupOptions, storage map[string]rest.Storage) error + + // ValidateOnCreate validates object creation. + ValidateOnCreate(ctx context.Context, obj T) error + + // ValidateOnUpdate validates object updates. + ValidateOnUpdate(ctx context.Context, oldObj, newObj T) error + + // ValidateOnDelete validates object deletion. + ValidateOnDelete(ctx context.Context, obj T) error +} + +// RoleApiInstaller provides Role-specific API registration and validation. +// This interface allows enterprise implementations to provide custom role handling +// while keeping the core IAM registration logic in OSS. +type RoleApiInstaller ApiInstaller[*iamv0.Role] + +// NoopApiInstaller is a no-op implementation for when roles are not available (OSS). +type NoopApiInstaller[T runtime.Object] struct { + ResourceInfo utils.ResourceInfo +} + +func (n *NoopApiInstaller[T]) GetAuthorizer() authorizer.Authorizer { + return authorizer.AuthorizerFunc(func(ctx context.Context, attr authorizer.Attributes) (authorizer.Decision, string, error) { + return authorizer.DecisionDeny, "Unavailable functionality", nil + }) +} + +func (n *NoopApiInstaller[T]) RegisterStorage(apiGroupInfo *server.APIGroupInfo, opts *builder.APIGroupOptions, storage map[string]rest.Storage) error { + storage[n.ResourceInfo.StoragePath()] = &noopstorage.NoopREST{ResourceInfo: n.ResourceInfo} + return nil +} + +func (n *NoopApiInstaller[T]) ValidateOnCreate(ctx context.Context, obj T) error { + // No validation needed in OSS + return nil +} + +func (n *NoopApiInstaller[T]) ValidateOnUpdate(ctx context.Context, oldObj, newObj T) error { + // No validation needed in OSS + return nil +} + +func (n *NoopApiInstaller[T]) ValidateOnDelete(ctx context.Context, obj T) error { + // No validation needed in OSS + return nil +} + +// ProvideNoopRoleApiInstaller provides a no-op role installer specifically for Role types. +// This is needed for Wire dependency injection which doesn't handle generic functions well. +func ProvideNoopRoleApiInstaller() RoleApiInstaller { + return &NoopApiInstaller[*iamv0.Role]{ + ResourceInfo: iamv0.RoleInfo, + } +} diff --git a/pkg/registry/apis/iam/authorizer.go b/pkg/registry/apis/iam/authorizer.go index 026254ebab6..8abc67bf885 100644 --- a/pkg/registry/apis/iam/authorizer.go +++ b/pkg/registry/apis/iam/authorizer.go @@ -19,7 +19,11 @@ type iamAuthorizer struct { resourceAuthorizer map[string]authorizer.Authorizer // Map resource to its authorizer } -func newIAMAuthorizer(accessClient authlib.AccessClient, legacyAccessClient authlib.AccessClient) authorizer.Authorizer { +func newIAMAuthorizer( + accessClient authlib.AccessClient, + legacyAccessClient authlib.AccessClient, + roleApiInstaller RoleApiInstaller, +) authorizer.Authorizer { resourceAuthorizer := make(map[string]authorizer.Authorizer) serviceAuthorizer := gfauthorizer.NewServiceAuthorizer() @@ -44,7 +48,7 @@ func newIAMAuthorizer(accessClient authlib.AccessClient, legacyAccessClient auth // Access specific resources authorizer := gfauthorizer.NewResourceAuthorizer(accessClient) resourceAuthorizer[iamv0.CoreRoleInfo.GetName()] = iamauthorizer.NewCoreRoleAuthorizer(accessClient) - resourceAuthorizer[iamv0.RoleInfo.GetName()] = authorizer + resourceAuthorizer[iamv0.RoleInfo.GetName()] = roleApiInstaller.GetAuthorizer() resourceAuthorizer[iamv0.ResourcePermissionInfo.GetName()] = allowAuthorizer // Handled by the backend wrapper resourceAuthorizer[iamv0.RoleBindingInfo.GetName()] = authorizer resourceAuthorizer[iamv0.ServiceAccountResourceInfo.GetName()] = authorizer diff --git a/pkg/registry/apis/iam/models.go b/pkg/registry/apis/iam/models.go index f8ae4219b65..9e4d8e484e4 100644 --- a/pkg/registry/apis/iam/models.go +++ b/pkg/registry/apis/iam/models.go @@ -56,7 +56,7 @@ type IdentityAccessManagementAPIBuilder struct { teamBindingLegacyStore *teambinding.LegacyBindingStore ssoLegacyStore *sso.LegacyStore coreRolesStorage CoreRoleStorageBackend - rolesStorage RoleStorageBackend + roleApiInstaller RoleApiInstaller resourcePermissionsStorage resource.StorageBackend roleBindingsStorage RoleBindingStorageBackend externalGroupMappingStorage ExternalGroupMappingStorageBackend diff --git a/pkg/registry/apis/iam/noopstorage/rest.go b/pkg/registry/apis/iam/noopstorage/rest.go new file mode 100644 index 00000000000..aa0214788ab --- /dev/null +++ b/pkg/registry/apis/iam/noopstorage/rest.go @@ -0,0 +1,74 @@ +package noopstorage + +import ( + "context" + + "k8s.io/apimachinery/pkg/apis/meta/internalversion" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/apiserver/pkg/registry/rest" + + "github.com/grafana/grafana/pkg/apimachinery/utils" +) + +type NoopREST struct { + ResourceInfo utils.ResourceInfo +} + +var ( + _ rest.Storage = (*NoopREST)(nil) + _ rest.Scoper = (*NoopREST)(nil) + _ rest.SingularNameProvider = (*NoopREST)(nil) + _ rest.Getter = (*NoopREST)(nil) +) + +func (n *NoopREST) New() runtime.Object { + return n.ResourceInfo.NewFunc() +} + +func (n *NoopREST) NewList() runtime.Object { + return n.ResourceInfo.NewListFunc() +} + +func (n *NoopREST) NamespaceScoped() bool { + return true +} + +func (n *NoopREST) GetSingularName() string { + return n.ResourceInfo.GetSingularName() +} + +func (n *NoopREST) Destroy() {} + +func (n *NoopREST) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { + return nil, errNoopStorage +} + +func (n *NoopREST) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { + return nil, errNoopStorage +} + +func (n *NoopREST) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) { + return nil, errNoopStorage +} + +func (n *NoopREST) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) { + return nil, false, errNoopStorage +} + +func (n *NoopREST) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) { + return nil, false, errNoopStorage +} + +func (n *NoopREST) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) { + return nil, errNoopStorage +} + +func (n *NoopREST) Watch(ctx context.Context, options *internalversion.ListOptions) (watch.Interface, error) { + return nil, errNoopStorage +} + +func (n *NoopREST) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { + return nil, errNoopStorage +} diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index a9a68e90d2c..8a3dcb9586b 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -72,7 +72,7 @@ func RegisterAPIService( zClient zanzana.Client, reg prometheus.Registerer, coreRolesStorage CoreRoleStorageBackend, - rolesStorage RoleStorageBackend, + roleApiInstaller RoleApiInstaller, tracing *tracing.TracingService, roleBindingsStorage RoleBindingStorageBackend, externalGroupMappingStorageBackend ExternalGroupMappingStorageBackend, @@ -87,7 +87,7 @@ func RegisterAPIService( dbProvider := legacysql.NewDatabaseProvider(sql) store := legacy.NewLegacySQLStores(dbProvider) legacyAccessClient := newLegacyAccessClient(ac, store) - authorizer := newIAMAuthorizer(accessClient, legacyAccessClient) + authorizer := newIAMAuthorizer(accessClient, legacyAccessClient, roleApiInstaller) registerMetrics(reg) //nolint:staticcheck // not yet migrated to OpenFeature @@ -106,7 +106,7 @@ func RegisterAPIService( teamBindingLegacyStore: teambinding.NewLegacyBindingStore(store, enableAuthnMutation, tracing), ssoLegacyStore: sso.NewLegacyStore(ssoService, tracing), coreRolesStorage: coreRolesStorage, - rolesStorage: rolesStorage, + roleApiInstaller: roleApiInstaller, resourcePermissionsStorage: resourcepermission.ProvideStorageBackend(dbProvider), roleBindingsStorage: roleBindingsStorage, externalGroupMappingStorage: externalGroupMappingStorageBackend, @@ -139,7 +139,7 @@ func NewAPIService( accessClient types.AccessClient, dbProvider legacysql.LegacyDatabaseProvider, coreRoleStorage CoreRoleStorageBackend, - roleStorage RoleStorageBackend, + roleApiInstaller RoleApiInstaller, features featuremgmt.FeatureToggles, zClient zanzana.Client, reg prometheus.Registerer, @@ -162,7 +162,6 @@ func NewAPIService( store: store, display: user.NewLegacyDisplayREST(store), resourcePermissionsStorage: resourcePermissionsStorage, - rolesStorage: roleStorage, coreRolesStorage: coreRoleStorage, roleBindingsStorage: noopstorage.ProvideStorageBackend(), // TODO: add a proper storage backend logger: log.New("iam.apis"), @@ -172,6 +171,7 @@ func NewAPIService( zClient: zClient, zTickets: make(chan bool, MaxConcurrentZanzanaWrites), reg: reg, + roleApiInstaller: roleApiInstaller, authorizer: authorizer.AuthorizerFunc( func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { user, ok := types.AuthInfoFrom(ctx) @@ -377,23 +377,17 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge } if enableZanzanaSync { b.logger.Info("Enabling hooks for CoreRole to sync to Zanzana") - coreRoleStore.AfterCreate = b.AfterRoleCreate - coreRoleStore.AfterDelete = b.AfterRoleDelete - coreRoleStore.BeginUpdate = b.BeginRoleUpdate + h := NewRoleHooks(b.zClient, b.zTickets, b.logger) + coreRoleStore.AfterCreate = h.AfterRoleCreate + coreRoleStore.AfterDelete = h.AfterRoleDelete + coreRoleStore.BeginUpdate = h.BeginRoleUpdate } storage[iamv0.CoreRoleInfo.StoragePath()] = coreRoleStore - roleStore, err := NewLocalStore(iamv0.RoleInfo, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.rolesStorage) - if err != nil { + // Role registration is delegated to the RoleApiInstaller + if err := b.roleApiInstaller.RegisterStorage(apiGroupInfo, &opts, storage); err != nil { return err } - if enableZanzanaSync { - b.logger.Info("Enabling hooks for Role to sync to Zanzana") - roleStore.AfterCreate = b.AfterRoleCreate - roleStore.AfterDelete = b.AfterRoleDelete - roleStore.BeginUpdate = b.BeginRoleUpdate - } - storage[iamv0.RoleInfo.StoragePath()] = roleStore roleBindingStore, err := NewLocalStore(iamv0.RoleBindingInfo, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.roleBindingsStorage) if err != nil { @@ -568,6 +562,8 @@ func (b *IdentityAccessManagementAPIBuilder) Validate(ctx context.Context, a adm return resourcepermission.ValidateCreateAndUpdateInput(ctx, typedObj) case *iamv0.ExternalGroupMapping: return externalgroupmapping.ValidateOnCreate(typedObj) + case *iamv0.Role: + return b.roleApiInstaller.ValidateOnCreate(ctx, typedObj) } return nil case admission.Update: @@ -592,9 +588,19 @@ func (b *IdentityAccessManagementAPIBuilder) Validate(ctx context.Context, a adm return fmt.Errorf("expected old object to be a TeamBinding, got %T", oldTeamBindingObj) } return teambinding.ValidateOnUpdate(ctx, typedObj, oldTeamBindingObj) + case *iamv0.Role: + oldRoleObj, ok := a.GetOldObject().(*iamv0.Role) + if !ok { + return fmt.Errorf("expected old object to be a Role, got %T", oldRoleObj) + } + return b.roleApiInstaller.ValidateOnUpdate(ctx, oldRoleObj, typedObj) } return nil case admission.Delete: + switch oldRoleObj := a.GetOldObject().(type) { + case *iamv0.Role: + return b.roleApiInstaller.ValidateOnDelete(ctx, oldRoleObj) + } return nil case admission.Connect: return nil diff --git a/pkg/registry/apis/iam/role_hooks.go b/pkg/registry/apis/iam/role_hooks.go index 831a2993fe5..8d86a35bd27 100644 --- a/pkg/registry/apis/iam/role_hooks.go +++ b/pkg/registry/apis/iam/role_hooks.go @@ -9,12 +9,23 @@ import ( "k8s.io/apiserver/pkg/registry/generic/registry" iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/accesscontrol" v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" "github.com/grafana/grafana/pkg/services/authz/zanzana" "github.com/grafana/grafana/pkg/services/authz/zanzana/common" ) +type RoleHooks struct { + zClient zanzana.Client + zTickets chan bool + logger log.Logger +} + +func NewRoleHooks(zClient zanzana.Client, zTickets chan bool, logger log.Logger) *RoleHooks { + return &RoleHooks{zClient: zClient, zTickets: zTickets, logger: logger} +} + // convertRolePermissionsToTuples converts role permissions (action/scope) to v1 TupleKey format // using the shared zanzana.ConvertRolePermissionsToTuples utility and common.ToAuthzExtTupleKeys func convertRolePermissionsToTuples(roleUID string, permissions []iamv0.CoreRolespecPermission) ([]*v1.TupleKey, error) { @@ -44,8 +55,8 @@ func convertRolePermissionsToTuples(roleUID string, permissions []iamv0.CoreRole // AfterRoleCreate is a post-create hook that writes the role permissions to Zanzana (openFGA) // It handles both Role and CoreRole types -func (b *IdentityAccessManagementAPIBuilder) AfterRoleCreate(obj runtime.Object, _ *metav1.CreateOptions) { - if b.zClient == nil { +func (h *RoleHooks) AfterRoleCreate(obj runtime.Object, _ *metav1.CreateOptions) { + if h.zClient == nil { return } @@ -76,21 +87,21 @@ func (b *IdentityAccessManagementAPIBuilder) AfterRoleCreate(obj runtime.Object, } wait := time.Now() - b.zTickets <- true + h.zTickets <- true hooksWaitHistogram.WithLabelValues(rType, "create").Observe(time.Since(wait).Seconds()) go func(role *iamv0.CoreRole, roleType string) { start := time.Now() status := "success" defer func() { - <-b.zTickets + <-h.zTickets hooksDurationHistogram.WithLabelValues(rType, "create", status).Observe(time.Since(start).Seconds()) hooksOperationCounter.WithLabelValues(rType, "create", status).Inc() }() tuples, err := convertRolePermissionsToTuples(role.Name, role.Spec.Permissions) if err != nil { - b.logger.Error("failed to convert role permissions to tuples", + h.logger.Error("failed to convert role permissions to tuples", "namespace", role.Namespace, "roleUID", role.Name, "roleType", roleType, @@ -103,7 +114,7 @@ func (b *IdentityAccessManagementAPIBuilder) AfterRoleCreate(obj runtime.Object, // Avoid writing if there are no valid tuples if len(tuples) == 0 { - b.logger.Debug("no valid tuples to write for role", + h.logger.Debug("no valid tuples to write for role", "namespace", role.Namespace, "roleUID", role.Name, "roleType", roleType, @@ -113,7 +124,7 @@ func (b *IdentityAccessManagementAPIBuilder) AfterRoleCreate(obj runtime.Object, return } - b.logger.Debug("writing role permissions to zanzana", + h.logger.Debug("writing role permissions to zanzana", "namespace", role.Namespace, "roleUID", role.Name, "roleType", roleType, @@ -124,14 +135,14 @@ func (b *IdentityAccessManagementAPIBuilder) AfterRoleCreate(obj runtime.Object, ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) defer cancel() - err = b.zClient.Write(ctx, &v1.WriteRequest{ + err = h.zClient.Write(ctx, &v1.WriteRequest{ Namespace: role.Namespace, Writes: &v1.WriteRequestWrites{ TupleKeys: tuples, }, }) if err != nil { - b.logger.Error("failed to write role permissions to zanzana", + h.logger.Error("failed to write role permissions to zanzana", "err", err, "namespace", role.Namespace, "roleUID", role.Name, @@ -149,8 +160,8 @@ func (b *IdentityAccessManagementAPIBuilder) AfterRoleCreate(obj runtime.Object, // AfterRoleDelete is a post-delete hook that removes the role permissions from Zanzana (openFGA) // It handles both Role and CoreRole types -func (b *IdentityAccessManagementAPIBuilder) AfterRoleDelete(obj runtime.Object, _ *metav1.DeleteOptions) { - if b.zClient == nil { +func (h *RoleHooks) AfterRoleDelete(obj runtime.Object, _ *metav1.DeleteOptions) { + if h.zClient == nil { return } @@ -182,15 +193,15 @@ func (b *IdentityAccessManagementAPIBuilder) AfterRoleDelete(obj runtime.Object, } wait := time.Now() - b.zTickets <- true + h.zTickets <- true hooksWaitHistogram.WithLabelValues("role", "delete").Observe(time.Since(wait).Seconds()) // Record wait time go func(role *iamv0.CoreRole, roleType string) { defer func() { - <-b.zTickets + <-h.zTickets }() - b.logger.Debug("deleting role permissions from zanzana", + h.logger.Debug("deleting role permissions from zanzana", "namespace", role.Namespace, "roleUID", role.Name, "roleType", roleType, @@ -199,7 +210,7 @@ func (b *IdentityAccessManagementAPIBuilder) AfterRoleDelete(obj runtime.Object, tuples, err := convertRolePermissionsToTuples(role.Name, role.Spec.Permissions) if err != nil { - b.logger.Error("failed to convert role permissions to tuples for deletion", + h.logger.Error("failed to convert role permissions to tuples for deletion", "namespace", role.Namespace, "roleUID", role.Name, "roleType", roleType, @@ -211,7 +222,7 @@ func (b *IdentityAccessManagementAPIBuilder) AfterRoleDelete(obj runtime.Object, // Avoid deleting if there are no valid tuples if len(tuples) == 0 { - b.logger.Debug("no valid tuples to delete for role", + h.logger.Debug("no valid tuples to delete for role", "namespace", role.Namespace, "roleUID", role.Name, "roleType", roleType, @@ -223,7 +234,7 @@ func (b *IdentityAccessManagementAPIBuilder) AfterRoleDelete(obj runtime.Object, // Convert tuples to TupleKeyWithoutCondition for deletion deleteTuples := toTupleKeysWithoutCondition(tuples) - b.logger.Debug("deleting role permissions from zanzana", + h.logger.Debug("deleting role permissions from zanzana", "namespace", role.Namespace, "roleUID", role.Name, "roleType", roleType, @@ -234,14 +245,14 @@ func (b *IdentityAccessManagementAPIBuilder) AfterRoleDelete(obj runtime.Object, ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) defer cancel() - err = b.zClient.Write(ctx, &v1.WriteRequest{ + err = h.zClient.Write(ctx, &v1.WriteRequest{ Namespace: role.Namespace, Deletes: &v1.WriteRequestDeletes{ TupleKeys: deleteTuples, }, }) if err != nil { - b.logger.Error("failed to delete role permissions from zanzana", + h.logger.Error("failed to delete role permissions from zanzana", "err", err, "namespace", role.Namespace, "roleUID", role.Name, @@ -255,8 +266,8 @@ func (b *IdentityAccessManagementAPIBuilder) AfterRoleDelete(obj runtime.Object, // beginRoleUpdate is a pre-update hook that prepares zanzana updates // It converts old and new permissions to tuples and performs the zanzana write after K8s update succeeds // It handles both Role and CoreRole types -func (b *IdentityAccessManagementAPIBuilder) BeginRoleUpdate(ctx context.Context, obj, oldObj runtime.Object, options *metav1.UpdateOptions) (registry.FinishFunc, error) { - if b.zClient == nil { +func (h *RoleHooks) BeginRoleUpdate(ctx context.Context, obj, oldObj runtime.Object, options *metav1.UpdateOptions) (registry.FinishFunc, error) { + if h.zClient == nil { return nil, nil } var oldRole, newRole *iamv0.CoreRole @@ -316,12 +327,12 @@ func (b *IdentityAccessManagementAPIBuilder) BeginRoleUpdate(ctx context.Context // Grab a ticket to write to Zanzana wait := time.Now() - b.zTickets <- true + h.zTickets <- true hooksWaitHistogram.WithLabelValues(roleType, "update").Observe(time.Since(wait).Seconds()) // Record wait time go func(old *iamv0.CoreRole, new *iamv0.CoreRole) { defer func() { - <-b.zTickets + <-h.zTickets }() roleUID, namespace := old.Name, old.Namespace oldPermissions, newPermissions := old.Spec.Permissions, new.Spec.Permissions @@ -332,7 +343,7 @@ func (b *IdentityAccessManagementAPIBuilder) BeginRoleUpdate(ctx context.Context var err error oldTuples, err = convertRolePermissionsToTuples(roleUID, oldPermissions) if err != nil { - b.logger.Error("failed to convert old role permissions to tuples", + h.logger.Error("failed to convert old role permissions to tuples", "namespace", namespace, "roleUID", roleUID, "roleType", roleType, @@ -344,7 +355,7 @@ func (b *IdentityAccessManagementAPIBuilder) BeginRoleUpdate(ctx context.Context // Convert new permissions to tuples for writing newTuples, err := convertRolePermissionsToTuples(roleUID, newPermissions) if err != nil { - b.logger.Error("failed to convert new role permissions to tuples", + h.logger.Error("failed to convert new role permissions to tuples", "namespace", namespace, "roleUID", roleUID, "roleType", roleType, @@ -353,7 +364,7 @@ func (b *IdentityAccessManagementAPIBuilder) BeginRoleUpdate(ctx context.Context return } - b.logger.Debug("updating role permissions in zanzana", + h.logger.Debug("updating role permissions in zanzana", "namespace", namespace, "roleUID", roleUID, "roleType", roleType, @@ -375,7 +386,7 @@ func (b *IdentityAccessManagementAPIBuilder) BeginRoleUpdate(ctx context.Context req.Deletes = &v1.WriteRequestDeletes{ TupleKeys: deleteTuples, } - b.logger.Debug("deleting existing role permissions from zanzana", + h.logger.Debug("deleting existing role permissions from zanzana", "namespace", namespace, "roleUID", roleUID, "roleType", roleType, @@ -388,7 +399,7 @@ func (b *IdentityAccessManagementAPIBuilder) BeginRoleUpdate(ctx context.Context req.Writes = &v1.WriteRequestWrites{ TupleKeys: newTuples, } - b.logger.Debug("writing new role permissions to zanzana", + h.logger.Debug("writing new role permissions to zanzana", "namespace", namespace, "roleUID", roleUID, "roleType", roleType, @@ -398,9 +409,9 @@ func (b *IdentityAccessManagementAPIBuilder) BeginRoleUpdate(ctx context.Context // Only make the request if there are deletes or writes if req.Deletes != nil || req.Writes != nil { - err = b.zClient.Write(ctx, req) + err = h.zClient.Write(ctx, req) if err != nil { - b.logger.Error("failed to update role permissions in zanzana", + h.logger.Error("failed to update role permissions in zanzana", "err", err, "namespace", namespace, "roleUID", roleUID, diff --git a/pkg/registry/apis/iam/role_hooks_test.go b/pkg/registry/apis/iam/role_hooks_test.go index 497150f1c61..847d0c7727a 100644 --- a/pkg/registry/apis/iam/role_hooks_test.go +++ b/pkg/registry/apis/iam/role_hooks_test.go @@ -53,7 +53,7 @@ func requireDeleteTuplesMatch(t *testing.T, actual []*v1.TupleKeyWithoutConditio func TestAfterCoreRoleCreate(t *testing.T) { var wg sync.WaitGroup - b := &IdentityAccessManagementAPIBuilder{ + h := &RoleHooks{ logger: log.NewNopLogger(), zTickets: make(chan bool, 1), } @@ -90,8 +90,8 @@ func TestAfterCoreRoleCreate(t *testing.T) { return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testCoreRoleEntries} - b.AfterRoleCreate(&coreRole, nil) + h.zClient = &FakeZanzanaClient{writeCallback: testCoreRoleEntries} + h.AfterRoleCreate(&coreRole, nil) wg.Wait() }) @@ -129,8 +129,8 @@ func TestAfterCoreRoleCreate(t *testing.T) { return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testDashboardRoleEntries} - b.AfterRoleCreate(&coreRole, nil) + h.zClient = &FakeZanzanaClient{writeCallback: testDashboardRoleEntries} + h.AfterRoleCreate(&coreRole, nil) wg.Wait() }) @@ -163,8 +163,8 @@ func TestAfterCoreRoleCreate(t *testing.T) { return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testWildcardEntries} - b.AfterRoleCreate(&coreRole, nil) + h.zClient = &FakeZanzanaClient{writeCallback: testWildcardEntries} + h.AfterRoleCreate(&coreRole, nil) wg.Wait() }) @@ -198,15 +198,15 @@ func TestAfterCoreRoleCreate(t *testing.T) { return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testMixedEntries} - b.AfterRoleCreate(&coreRole, nil) + h.zClient = &FakeZanzanaClient{writeCallback: testMixedEntries} + h.AfterRoleCreate(&coreRole, nil) wg.Wait() }) } func TestAfterRoleCreate(t *testing.T) { var wg sync.WaitGroup - b := &IdentityAccessManagementAPIBuilder{ + h := &RoleHooks{ logger: log.NewNopLogger(), zTickets: make(chan bool, 1), } @@ -243,8 +243,8 @@ func TestAfterRoleCreate(t *testing.T) { return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testRoleEntries} - b.AfterRoleCreate(&role, nil) + h.zClient = &FakeZanzanaClient{writeCallback: testRoleEntries} + h.AfterRoleCreate(&role, nil) wg.Wait() }) @@ -281,8 +281,8 @@ func TestAfterRoleCreate(t *testing.T) { return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testDashRoleEntries} - b.AfterRoleCreate(&role, nil) + h.zClient = &FakeZanzanaClient{writeCallback: testDashRoleEntries} + h.AfterRoleCreate(&role, nil) wg.Wait() }) @@ -317,15 +317,15 @@ func TestAfterRoleCreate(t *testing.T) { return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testMergedEntries} - b.AfterRoleCreate(&role, nil) + h.zClient = &FakeZanzanaClient{writeCallback: testMergedEntries} + h.AfterRoleCreate(&role, nil) wg.Wait() }) } func TestBeginCoreRoleUpdate(t *testing.T) { var wg sync.WaitGroup - b := &IdentityAccessManagementAPIBuilder{ + h := &RoleHooks{ logger: log.NewNopLogger(), zTickets: make(chan bool, 1), } @@ -387,10 +387,10 @@ func TestBeginCoreRoleUpdate(t *testing.T) { return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testUpdate} + h.zClient = &FakeZanzanaClient{writeCallback: testUpdate} // Call BeginUpdate which does all the work - finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil) + finishFunc, err := h.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil) require.NoError(t, err) require.NotNil(t, finishFunc) @@ -445,10 +445,10 @@ func TestBeginCoreRoleUpdate(t *testing.T) { return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testExpand} + h.zClient = &FakeZanzanaClient{writeCallback: testExpand} // Call BeginUpdate which does all the work - finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil) + finishFunc, err := h.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil) require.NoError(t, err) require.NotNil(t, finishFunc) @@ -499,10 +499,10 @@ func TestBeginCoreRoleUpdate(t *testing.T) { return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testClear} + h.zClient = &FakeZanzanaClient{writeCallback: testClear} // Call BeginUpdate which does all the work - finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil) + finishFunc, err := h.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil) require.NoError(t, err) require.NotNil(t, finishFunc) @@ -514,7 +514,7 @@ func TestBeginCoreRoleUpdate(t *testing.T) { func TestBeginRoleUpdate(t *testing.T) { var wg sync.WaitGroup - b := &IdentityAccessManagementAPIBuilder{ + h := &RoleHooks{ logger: log.NewNopLogger(), zTickets: make(chan bool, 1), } @@ -577,10 +577,10 @@ func TestBeginRoleUpdate(t *testing.T) { return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testUpdate} + h.zClient = &FakeZanzanaClient{writeCallback: testUpdate} // Call BeginUpdate which does all the work - finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil) + finishFunc, err := h.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil) require.NoError(t, err) require.NotNil(t, finishFunc) @@ -640,10 +640,10 @@ func TestBeginRoleUpdate(t *testing.T) { return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testSwap} + h.zClient = &FakeZanzanaClient{writeCallback: testSwap} // Call BeginUpdate which does all the work - finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil) + finishFunc, err := h.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil) require.NoError(t, err) require.NotNil(t, finishFunc) @@ -695,10 +695,10 @@ func TestBeginRoleUpdate(t *testing.T) { return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testAddToEmpty} + h.zClient = &FakeZanzanaClient{writeCallback: testAddToEmpty} // Call BeginUpdate which does all the work - finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil) + finishFunc, err := h.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil) require.NoError(t, err) require.NotNil(t, finishFunc) @@ -710,7 +710,7 @@ func TestBeginRoleUpdate(t *testing.T) { func TestAfterCoreRoleDelete(t *testing.T) { var wg sync.WaitGroup - b := &IdentityAccessManagementAPIBuilder{ + h := &RoleHooks{ logger: log.NewNopLogger(), zTickets: make(chan bool, 1), } @@ -747,8 +747,8 @@ func TestAfterCoreRoleDelete(t *testing.T) { return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testCoreRoleDeletes} - b.AfterRoleDelete(&coreRole, nil) + h.zClient = &FakeZanzanaClient{writeCallback: testCoreRoleDeletes} + h.AfterRoleDelete(&coreRole, nil) wg.Wait() }) @@ -785,8 +785,8 @@ func TestAfterCoreRoleDelete(t *testing.T) { return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testDashboardRoleDeletes} - b.AfterRoleDelete(&coreRole, nil) + h.zClient = &FakeZanzanaClient{writeCallback: testDashboardRoleDeletes} + h.AfterRoleDelete(&coreRole, nil) wg.Wait() }) @@ -818,8 +818,8 @@ func TestAfterCoreRoleDelete(t *testing.T) { return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testWildcardDeletes} - b.AfterRoleDelete(&coreRole, nil) + h.zClient = &FakeZanzanaClient{writeCallback: testWildcardDeletes} + h.AfterRoleDelete(&coreRole, nil) wg.Wait() }) @@ -853,15 +853,15 @@ func TestAfterCoreRoleDelete(t *testing.T) { return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testMixedDeletes} - b.AfterRoleDelete(&coreRole, nil) + h.zClient = &FakeZanzanaClient{writeCallback: testMixedDeletes} + h.AfterRoleDelete(&coreRole, nil) wg.Wait() }) } func TestAfterRoleDelete(t *testing.T) { var wg sync.WaitGroup - b := &IdentityAccessManagementAPIBuilder{ + h := &RoleHooks{ logger: log.NewNopLogger(), zTickets: make(chan bool, 1), } @@ -898,8 +898,8 @@ func TestAfterRoleDelete(t *testing.T) { return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testRoleDeletes} - b.AfterRoleDelete(&role, nil) + h.zClient = &FakeZanzanaClient{writeCallback: testRoleDeletes} + h.AfterRoleDelete(&role, nil) wg.Wait() }) @@ -936,8 +936,8 @@ func TestAfterRoleDelete(t *testing.T) { return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testDashRoleDeletes} - b.AfterRoleDelete(&role, nil) + h.zClient = &FakeZanzanaClient{writeCallback: testDashRoleDeletes} + h.AfterRoleDelete(&role, nil) wg.Wait() }) @@ -982,8 +982,8 @@ func TestAfterRoleDelete(t *testing.T) { return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testMultiDeletes} - b.AfterRoleDelete(&role, nil) + h.zClient = &FakeZanzanaClient{writeCallback: testMultiDeletes} + h.AfterRoleDelete(&role, nil) wg.Wait() }) } diff --git a/pkg/registry/apis/wireset.go b/pkg/registry/apis/wireset.go index df38965759b..d95f01bef36 100644 --- a/pkg/registry/apis/wireset.go +++ b/pkg/registry/apis/wireset.go @@ -28,7 +28,7 @@ import ( var WireSetExts = wire.NewSet( noopstorage.ProvideStorageBackend, wire.Bind(new(iam.CoreRoleStorageBackend), new(*noopstorage.StorageBackendImpl)), - wire.Bind(new(iam.RoleStorageBackend), new(*noopstorage.StorageBackendImpl)), + iam.ProvideNoopRoleApiInstaller, wire.Bind(new(iam.RoleBindingStorageBackend), new(*noopstorage.StorageBackendImpl)), wire.Bind(new(iam.ExternalGroupMappingStorageBackend), new(*noopstorage.StorageBackendImpl)), diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 478ef749f11..676b0605e83 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -882,8 +882,9 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api } folderAPIBuilder := folders.RegisterAPIService(cfg, featureToggles, apiserverService, folderimplService, folderPermissionsService, accessControl, acimplService, accessClient, registerer, resourceClient, zanzanaClient) storageBackendImpl := noopstorage.ProvideStorageBackend() + roleApiInstaller := iam.ProvideNoopRoleApiInstaller() noopTeamGroupsREST := externalgroupmapping.ProvideNoopTeamGroupsREST() - identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(cfg, featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, tracingService, storageBackendImpl, storageBackendImpl, noopTeamGroupsREST, dualwriteService, resourceClient, orgService, userService, teamService, eventualRestConfigProvider) + identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(cfg, featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, roleApiInstaller, tracingService, storageBackendImpl, storageBackendImpl, noopTeamGroupsREST, dualwriteService, resourceClient, orgService, userService, teamService, eventualRestConfigProvider) if err != nil { return nil, err } @@ -1543,8 +1544,9 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac } folderAPIBuilder := folders.RegisterAPIService(cfg, featureToggles, apiserverService, folderimplService, folderPermissionsService, accessControl, acimplService, accessClient, registerer, resourceClient, zanzanaClient) storageBackendImpl := noopstorage.ProvideStorageBackend() + roleApiInstaller := iam.ProvideNoopRoleApiInstaller() noopTeamGroupsREST := externalgroupmapping.ProvideNoopTeamGroupsREST() - identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(cfg, featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, tracingService, storageBackendImpl, storageBackendImpl, noopTeamGroupsREST, dualwriteService, resourceClient, orgService, userService, teamService, eventualRestConfigProvider) + identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(cfg, featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, roleApiInstaller, tracingService, storageBackendImpl, storageBackendImpl, noopTeamGroupsREST, dualwriteService, resourceClient, orgService, userService, teamService, eventualRestConfigProvider) if err != nil { return nil, err } From f75b5654c9a23524a829cca61532a350247bb6cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Thu, 18 Dec 2025 11:20:48 +0100 Subject: [PATCH 010/229] Modal: Fix modal button row (#115483) * Modal: Fix modal button row * update * update --- packages/grafana-ui/src/components/Modal/getModalStyles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/grafana-ui/src/components/Modal/getModalStyles.ts b/packages/grafana-ui/src/components/Modal/getModalStyles.ts index 3c124d24e68..f1e92cfa3f6 100644 --- a/packages/grafana-ui/src/components/Modal/getModalStyles.ts +++ b/packages/grafana-ui/src/components/Modal/getModalStyles.ts @@ -79,7 +79,7 @@ export const getModalStyles = (theme: GrafanaTheme2) => { modalContent: css({ overflow: 'auto', padding: theme.spacing(3, 3, 0, 3), - marginBottom: theme.spacing(3), + marginBottom: theme.spacing(2.5), scrollbarWidth: 'thin', width: '100%', From 2123099e882c7cff6f914ce81ab089fa7e1e56ac Mon Sep 17 00:00:00 2001 From: Gonzalo Trigueros Manzanas <242162051+gttrigger@users.noreply.github.com> Date: Thu, 18 Dec 2025 11:28:10 +0100 Subject: [PATCH 011/229] Provisioning: escape URLs in PR comments to avoid malformed markdown. (#115486) provisioning: escape URLs in webhook changes to allow for proper markdown. --- .../webhooks/pullrequest/changes.go | 15 ++-- .../webhooks/pullrequest/changes_test.go | 74 ++++++++++++++++++- 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/pkg/registry/apis/provisioning/webhooks/pullrequest/changes.go b/pkg/registry/apis/provisioning/webhooks/pullrequest/changes.go index f8e485e8fe5..d6fec09bc96 100644 --- a/pkg/registry/apis/provisioning/webhooks/pullrequest/changes.go +++ b/pkg/registry/apis/provisioning/webhooks/pullrequest/changes.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "net/url" - "path" "strings" "time" @@ -141,14 +140,20 @@ func (e *evaluator) evaluateFile(ctx context.Context, repo repository.Reader, ba if info.Parsed.GVK.Kind == dashboardKind { // FIXME: extract the logic out of a dashboard URL builder/injector or similar // for testability and decoupling + urlBuilder, err := url.Parse(baseURL) + if err != nil { + info.Error = err.Error() + return info + } + if info.Parsed.Existing != nil { - info.GrafanaURL = fmt.Sprintf("%sd/%s/%s", baseURL, obj.GetName(), - slugify.Slugify(info.Title)) + grafanaURL := urlBuilder.JoinPath("d", obj.GetName(), slugify.Slugify(info.Title)) + info.GrafanaURL = grafanaURL.String() } // Load this file directly - info.PreviewURL = baseURL + path.Join("admin/provisioning", - info.Parsed.Repo.Name, "dashboard/preview", info.Parsed.Info.Path) + previewURL := urlBuilder.JoinPath("admin/provisioning", info.Parsed.Repo.Name, "dashboard/preview", info.Parsed.Info.Path) + info.PreviewURL = previewURL.String() query := url.Values{} query.Set("ref", info.Parsed.Info.Ref) diff --git a/pkg/registry/apis/provisioning/webhooks/pullrequest/changes_test.go b/pkg/registry/apis/provisioning/webhooks/pullrequest/changes_test.go index c8f0c33e92a..6c513830d29 100644 --- a/pkg/registry/apis/provisioning/webhooks/pullrequest/changes_test.go +++ b/pkg/registry/apis/provisioning/webhooks/pullrequest/changes_test.go @@ -737,8 +737,78 @@ func TestCalculateChanges(t *testing.T) { Path: "path/to/file.json", Ref: "ref", }, - GrafanaURL: "ht tp://bad url/d/the-uid/hello-world", // Malformed URL - PreviewURL: "ht tp://bad url/admin/provisioning/y/dashboard/preview/path/to/file.json?pull_request_url=http%253A%252F%252Fgithub.com%252Fpr%252F&ref=ref", + Error: "parse \"ht tp://bad url/\": first path segment in URL cannot contain colon", + }}, + }, + }, + { + name: "path with spaces", + setupMocks: func(parser *resources.MockParser, reader *repository.MockReader, progress *jobs.MockJobProgressRecorder, renderer *MockScreenshotRenderer, parserFactory *resources.MockParserFactory) { + finfo := &repository.FileInfo{ + Path: "path/to/file with spaces.json", + Ref: "ref", + Data: []byte("xxxx"), + } + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": resources.DashboardResource.GroupVersion().String(), + "kind": dashboardKind, + "metadata": map[string]interface{}{ + "name": "the-uid", + }, + "spec": map[string]interface{}{ + "title": "hello world", + }, + }, + } + meta, _ := utils.MetaAccessor(obj) + + progress.On("SetMessage", mock.Anything, "process path/to/file with spaces.json").Return() + reader.On("Read", mock.Anything, "path/to/file with spaces.json", "ref").Return(finfo, nil) + reader.On("Config").Return(&provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "x", + }, + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + GenerateDashboardPreviews: true, + }, + }, + }) + parser.On("Parse", mock.Anything, finfo).Return(&resources.ParsedResource{ + Info: finfo, + Repo: provisioning.ResourceRepositoryInfo{ + Namespace: "x", + Name: "y", + }, + GVK: schema.GroupVersionKind{ + Kind: dashboardKind, + }, + Obj: obj, + Existing: obj, + Meta: meta, + DryRunResponse: obj, + }, nil) + renderer.On("IsAvailable", mock.Anything, mock.Anything).Return(false) + parserFactory.On("GetParser", mock.Anything, mock.Anything).Return(parser, nil) + }, + changes: []repository.VersionedFileChange{{ + Action: repository.FileActionCreated, + Path: "path/to/file with spaces.json", + Ref: "ref", + }}, + expectedInfo: changeInfo{ + Changes: []fileChangeInfo{{ + Change: repository.VersionedFileChange{ + Action: repository.FileActionCreated, + Path: "path/to/file with spaces.json", + Ref: "ref", + }, + GrafanaURL: "http://host/d/the-uid/hello-world", + PreviewURL: "http://host/admin/provisioning/y/dashboard/preview/path/to/file%20with%20spaces.json?pull_request_url=http%253A%252F%252Fgithub.com%252Fpr%252F&ref=ref", + GrafanaScreenshotURL: "", + PreviewScreenshotURL: "", }}, }, }, From 5bedcc7bd7cf0d8636c1d0839466404f0790e48f Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Thu, 18 Dec 2025 11:47:38 +0100 Subject: [PATCH 012/229] Frontend: use custom conditions for development and build (#111685) * build(frontend): enable custom condition for resolving source files during dev and build * feat(packages): apply conditional name to export properties * chore(packages): add standard exports to flamegraph and prometheus * chore(packages): resolve main, module, types to built files * build(packages): clean up prepare-npm-package for custom condition changes * refactor(packages): reduce repetition in conditional exports * build(storybook): add @grafana-app/source to conditionNames * test(frontend): add grafana-app/source customCondition for jest tests * refactor(frontend): remove nested package import paths * chore(jest): use customExportConditions for source files and browser * chore(i18n): use src for ./eslint-plugin export * chore(packages): set packages tsconfigs to moduleResolution bundler * chore(packages): fix rollup builds * build(packages): build cjs as multiple files * chore(sql): reference MonitoringLogger for moduleresolution bundler to pass typecheck * chore(ui): add type refs for moduleresolution bundler to pass typecheck * feat(schema): add exports for cleaner import paths * refactor(frontend): clean up schema paths to point to exports instead of nested file paths * build(storybook): hack the builder-manager for custom conditions to resolve * build(decoupled-plugins): fix broken builds due to missing conditionNames * chore(e2e): pass condition to playwright to resolve local packages * build(frontend): fix failing build * chore(select): fix typings * style(frontend): clean up eslint suppressions * chore(packages): fix type errors due to incorrect tsconfig settings * build(generate-apis): use swc with ts-node and moduleResolution bundler * chore(cypress): add conditionNames to resolve monorepo packages * build(npm): update prepare to work with latest exports changes * build(packages): fix prepare-npm-package script * fix(e2e-selectors): update debugoverlay for data-testid change * build(packages): stop editing package.json at pack n publish time * rerun ci * chore(api-clients): use moduleResolution: bundler for customConditions support * chore(api-clients): fix generation * build(packages): remove aliasing exports, remove exports with only customConditions * Revert "refactor(frontend): clean up schema paths to point to exports instead of nested file paths" This reverts commit 7949b6ea0e60e51989d2a8149b7a24647cd68916. * revert(schema): remove exports from package so builds work * build(api-clients): fix up api-clients exports and rollup config * build(api-clients): Update generated package exports for api clients * build(schema): add overrides to cjsOutput and esmOutput so built directory structure is correct * fix(packages): use rootDirs to prevent types/src directories in built d.ts file paths * build(packages): prevent empty exports added to package.json during pack * docs(packages): update readme with custom conditions information --------- Co-authored-by: Tom Ratcliffe --- ...@storybook-core-npm-8.6.2-8c752112c0.patch | 13 ++ e2e/cypress/plugins/typescriptPreprocessor.js | 1 + eslint-suppressions.json | 3 - jest.config.js | 3 + package.json | 13 +- packages/README.md | 31 +++- packages/grafana-alerting/package.json | 38 +++-- packages/grafana-alerting/rollup.config.ts | 6 +- packages/grafana-api-clients/package.json | 157 +++++++++++------- packages/grafana-api-clients/rollup.config.ts | 22 +-- .../src/generator/helpers.ts | 6 +- packages/grafana-api-clients/tsconfig.json | 12 +- packages/grafana-data/package.json | 35 ++-- packages/grafana-data/rollup.config.ts | 4 +- packages/grafana-data/tsconfig.json | 4 +- packages/grafana-e2e-selectors/package.json | 17 +- .../grafana-e2e-selectors/rollup.config.ts | 2 +- .../src/selectors/components.ts | 1 + packages/grafana-e2e-selectors/tsconfig.json | 3 +- packages/grafana-flamegraph/package.json | 17 +- packages/grafana-flamegraph/rollup.config.ts | 2 +- packages/grafana-flamegraph/tsconfig.json | 3 +- packages/grafana-i18n/package.json | 23 ++- packages/grafana-i18n/rollup.config.ts | 10 +- packages/grafana-i18n/tsconfig.json | 3 +- .../grafana-o11y-ds-frontend/tsconfig.json | 3 +- .../jest/jest.config.js | 3 + packages/grafana-plugin-configs/tsconfig.json | 7 +- .../grafana-plugin-configs/webpack.config.ts | 1 + packages/grafana-prometheus/package.json | 17 +- packages/grafana-prometheus/rollup.config.ts | 2 +- packages/grafana-runtime/package.json | 32 ++-- packages/grafana-runtime/rollup.config.ts | 4 +- packages/grafana-runtime/src/index.ts | 10 +- packages/grafana-runtime/tsconfig.json | 3 +- packages/grafana-schema/package.json | 9 +- packages/grafana-schema/rollup.config.ts | 7 +- packages/grafana-schema/tsconfig.json | 4 +- packages/grafana-sql/src/utils/logging.ts | 4 +- packages/grafana-sql/tsconfig.json | 3 +- packages/grafana-ui/.storybook/main.ts | 10 ++ packages/grafana-ui/.storybook/tsconfig.json | 3 +- packages/grafana-ui/package.json | 32 ++-- packages/grafana-ui/rollup.config.ts | 4 +- .../Forms/Legacy/Select/SelectOptionGroup.tsx | 3 +- .../src/components/Select/ValueContainer.tsx | 3 +- packages/grafana-ui/tsconfig.json | 3 +- packages/rollup.config.parts.ts | 12 +- project.json | 3 +- .../app/features/admin/UserListAdminPage.tsx | 2 +- .../features/admin/UserListAnonymousPage.tsx | 2 +- .../app/features/admin/UserListPage.test.tsx | 2 +- .../DashboardsListModalButton.tsx | 2 +- .../UserListPublicDashboardPage.tsx | 2 +- .../inspect/InspectJsonTab.tsx | 2 +- .../layout-auto-grid/AutoGridItemRenderer.tsx | 2 +- .../components/DashNav/ShareButton.tsx | 2 +- .../PublicDashboardNotAvailable.tsx | 2 +- .../ConfigPublicDashboard.tsx | 2 +- .../ConfigPublicDashboard/Configuration.tsx | 2 +- .../AcknowledgeCheckboxes.tsx | 2 +- .../ModalAlerts/NoUpsertPermissionsAlert.tsx | 2 +- .../UnsupportedDataSourcesAlert.tsx | 2 +- .../UnsupportedTemplateVariablesAlert.tsx | 2 +- .../SharePublicDashboard.test.tsx | 2 +- .../containers/PublicDashboardPage.test.tsx | 2 +- .../containers/PublicDashboardPage.tsx | 2 +- .../PrometheusListView/RawListItem.tsx | 2 +- .../logs/components/panel/LogListControls.tsx | 2 +- .../PublicDashboardListTable.test.tsx | 2 +- .../PublicDashboardListTable.tsx | 2 +- .../panel/geomap/components/DebugOverlay.tsx | 4 +- scripts/cli/tsconfig.json | 4 +- scripts/prepare-npm-package.js | 111 +++---------- scripts/tsconfig.base.json | 1 + scripts/validate-npm-packages.sh | 2 +- scripts/webpack/webpack.common.js | 1 + tsconfig.json | 3 +- yarn.lock | 24 +++ 79 files changed, 440 insertions(+), 365 deletions(-) create mode 100644 .yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch diff --git a/.yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch b/.yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch new file mode 100644 index 00000000000..730ecce8fb2 --- /dev/null +++ b/.yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch @@ -0,0 +1,13 @@ +diff --git a/dist/builder-manager/index.js b/dist/builder-manager/index.js +index 3d7f9b213dae1801bda62b31db31b9113e382ccd..212501c63d20146c29db63fb0f6300c6779eecb5 100644 +--- a/dist/builder-manager/index.js ++++ b/dist/builder-manager/index.js +@@ -1970,7 +1970,7 @@ var pa = /^\/($|\?)/, G, C, xt = /* @__PURE__ */ o(async (e) => { + bundle: !0, + minify: !0, + sourcemap: !1, +- conditions: ["browser", "module", "default"], ++ conditions: ["@grafana-app/source", "browser", "module", "default"], + jsxFactory: "React.createElement", + jsxFragment: "React.Fragment", + jsx: "transform", diff --git a/e2e/cypress/plugins/typescriptPreprocessor.js b/e2e/cypress/plugins/typescriptPreprocessor.js index 27765380994..ed054f6f943 100644 --- a/e2e/cypress/plugins/typescriptPreprocessor.js +++ b/e2e/cypress/plugins/typescriptPreprocessor.js @@ -18,6 +18,7 @@ const webpackOptions = { }, resolve: { extensions: ['.ts', '.js'], + conditionNames: ['@grafana-app/source', '...'], }, }; diff --git a/eslint-suppressions.json b/eslint-suppressions.json index dfa69ae64f4..08f37b316d6 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -4246,9 +4246,6 @@ } }, "public/app/plugins/panel/geomap/components/DebugOverlay.tsx": { - "@grafana/no-aria-label-selectors": { - "count": 1 - }, "react-prefer-function-component/react-prefer-function-component": { "count": 1 } diff --git a/jest.config.js b/jest.config.js index ef107c8cc24..17a2ce9ca32 100644 --- a/jest.config.js +++ b/jest.config.js @@ -40,6 +40,9 @@ const esModules = [ module.exports = { verbose: false, testEnvironment: 'jsdom', + testEnvironmentOptions: { + customExportConditions: ['@grafana-app/source', 'browser'], + }, transform: { '^.+\\.(ts|tsx|js|jsx)$': [require.resolve('ts-jest')], }, diff --git a/package.json b/package.json index e148217cac3..f2300869740 100644 --- a/package.json +++ b/package.json @@ -26,10 +26,10 @@ "e2e:enterprise": "./e2e/start-and-run-suite enterprise", "e2e:enterprise:dev": "./e2e/start-and-run-suite enterprise dev", "e2e:enterprise:debug": "./e2e/start-and-run-suite enterprise debug", - "e2e:playwright": "yarn playwright test --grep-invert @cloud-plugins", - "e2e:playwright:cloud-plugins": "yarn playwright test --grep @cloud-plugins", - "e2e:playwright:storybook": "yarn playwright test -c playwright.storybook.config.ts", - "e2e:acceptance": "yarn playwright test --grep @acceptance", + "e2e:playwright": "NODE_OPTIONS='-C @grafana-app/source' yarn playwright test --grep-invert @cloud-plugins", + "e2e:playwright:cloud-plugins": "NODE_OPTIONS='-C @grafana-app/source' yarn playwright test --grep @cloud-plugins", + "e2e:playwright:storybook": "NODE_OPTIONS='-C @grafana-app/source' yarn playwright test -c playwright.storybook.config.ts", + "e2e:acceptance": "NODE_OPTIONS='-C @grafana-app/source' yarn playwright test --grep @acceptance", "e2e:storybook": "PORT=9001 ./e2e/run-suite storybook true", "e2e:plugin:build": "nx run-many -t build --projects='@test-plugins/*'", "e2e:plugin:build:dev": "nx run-many -t dev --projects='@test-plugins/*' --maxParallel=100", @@ -63,7 +63,7 @@ "storybook": "yarn workspace @grafana/ui storybook --ci", "storybook:build": "yarn workspace @grafana/ui storybook:build", "themes-schema": "typescript-json-schema ./tsconfig.json NewThemeOptions --include 'packages/grafana-data/src/themes/createTheme.ts' --out public/app/features/theme-playground/schema.generated.json", - "themes-generate": "yarn themes-schema && esbuild --target=es6 ./scripts/cli/generateSassVariableFiles.ts --bundle --platform=node --tsconfig=./scripts/cli/tsconfig.json | node", + "themes-generate": "yarn themes-schema && esbuild --target=es6 ./scripts/cli/generateSassVariableFiles.ts --bundle --conditions=@grafana-app/source --platform=node --tsconfig=./scripts/cli/tsconfig.json | node", "themes:usage": "eslint . --ignore-pattern '*.test.ts*' --ignore-pattern '*.spec.ts*' --cache --plugin '@grafana' --rule '{ @grafana/theme-token-usage: \"error\" }'", "typecheck": "tsc --noEmit && yarn run packages:typecheck", "plugins:build-bundled": "echo 'bundled plugins are no longer supported'", @@ -460,7 +460,8 @@ "tmp@npm:^0.0.33": "~0.2.1", "js-yaml@npm:4.1.0": "^4.1.0", "js-yaml@npm:=4.1.0": "^4.1.0", - "nodemailer": "7.0.7" + "nodemailer": "7.0.7", + "@storybook/core@npm:8.6.2": "patch:@storybook/core@npm%3A8.6.2#~/.yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch" }, "workspaces": { "packages": [ diff --git a/packages/README.md b/packages/README.md index b42f7082c5c..3fa0dceebc7 100644 --- a/packages/README.md +++ b/packages/README.md @@ -2,13 +2,32 @@ ## Exporting code conventions -`@grafana/ui`, `@grafana/data` and `@grafana/runtime` makes use of `exports` in package.json to define three entrypoints that Grafana core and Grafana plugins can access. Before exposing anything in these packages please consider the table below to better understand the use case of each export. +All the `@grafana` packages in this repo (except `@grafana/schema`) make use of `exports` in package.json to define entrypoints that Grafana core and Grafana plugins can access. Exports can also be used to restrict access to internal files in packages. -| Export Name | Import Path | Description | Available to Grafana | Available to plugins | -| ------------ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | -------------------- | -| `./` | `@grafana/ui` | The public API entrypoint. If the code is stable and you want to share it everywhere, this is the place to export it. | ✅ | ✅ | -| `./unstable` | `@grafana/ui/unstable` | The public API entrypoint for all experimental code. If you want to iterate and test code from Grafana and plugins, this is the place to export it. | ✅ | ✅ | -| `./internal` | `@grafana/ui/internal` | The private API entrypoint for internal code shared with Grafana. If you need to import code in Grafana but don't want to expose it to plugins, this is the place to export it. | ✅ | ❌ | +Package authors are free to create as many exports as they like but should consider the following points: + +1. Resolution of source code within this repo is handled by the [customCondition](https://www.typescriptlang.org/tsconfig/#customConditions) `@grafana-app/source`. This allows the frontend tooling in this repo to resolve to the source code preventing the need to build all the packages up front. When adding exports it is important to add an entry for the custom condition as the first item. All other entries should point to the built, bundled files. For example: + + ```json + "exports": { + ".": { + "@grafana-app/source": "./src/index.ts", + "types": "./dist/types/index.d.ts", + "import": "./dist/esm/index.mjs", + "require": "./dist/cjs/index.cjs" + } + } + ``` + +2. If you add exports to your package you must export the `package.json` file. + +3. Before exposing anything in these packages please consider the table below to better understand the conventions we have put in place for most of the packages in this repository. + +| Export Name | Import Path | Description | Available to Grafana | Available to plugins | +| ------------ | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | -------------------- | +| `./` | `@grafana/ui` | The public API entrypoint. If the code is stable and you want to share it everywhere, this is the place to export it. | ✅ | ✅ | +| `./unstable` | `@grafana/ui/unstable` | The public API entrypoint for all experimental code. If you want to iterate and test code from Grafana and plugins, this is the place to export it. | ✅ | ✅ | +| `./internal` | `@grafana/ui/internal` | The private API entrypoint for internal code shared with Grafana. If you want to co-locate code in a package with it's public API but only want the Grafana application to access it, this is the place to export it. | ✅ | ❌ | ## Versioning diff --git a/packages/grafana-alerting/package.json b/packages/grafana-alerting/package.json index 1c3ab2bf9b4..d64f1c01893 100644 --- a/packages/grafana-alerting/package.json +++ b/packages/grafana-alerting/package.json @@ -17,32 +17,34 @@ "url": "http://github.com/grafana/grafana.git", "directory": "packages/grafana-alerting" }, - "main": "src/index.ts", - "types": "src/index.ts", - "module": "src/index.ts", + "main": "./dist/cjs/index.cjs", + "module": "./dist/esm/index.mjs", + "types": "./dist/types/index.d.ts", "exports": { "./package.json": "./package.json", ".": { - "import": "./src/index.ts", - "require": "./src/index.ts" - }, - "./internal": { - "import": "./src/internal.ts", - "require": "./src/internal.ts" + "@grafana-app/source": "./src/index.ts", + "types": "./dist/types/index.d.ts", + "import": "./dist/esm/index.mjs", + "require": "./dist/cjs/index.cjs" }, "./unstable": { - "import": "./src/unstable.ts", - "require": "./src/unstable.ts" + "@grafana-app/source": "./src/unstable.ts", + "types": "./dist/types/unstable.d.ts", + "import": "./dist/esm/unstable.mjs", + "require": "./dist/cjs/unstable.cjs" + }, + "./internal": { + "@grafana-app/source": "./src/internal.ts" }, "./testing": { - "import": "./src/testing.ts", - "require": "./src/testing.ts" + "@grafana-app/source": "./src/testing.ts", + "types": "./dist/types/testing.d.ts", + "import": "./dist/esm/testing.mjs", + "require": "./dist/cjs/testing.cjs" } }, "publishConfig": { - "main": "./dist/cjs/index.cjs", - "module": "./dist/esm/index.mjs", - "types": "./dist/types/index.d.ts", "access": "public" }, "files": [ @@ -57,8 +59,8 @@ "clean": "rimraf ./dist ./compiled ./unstable ./testing ./package.tgz", "typecheck": "tsc --emitDeclarationOnly false --noEmit", "codegen": "rtk-query-codegen-openapi ./scripts/codegen.ts", - "prepack": "cp package.json package.json.bak && ALIAS_PACKAGE_NAME=testing,unstable node ../../scripts/prepare-npm-package.js", - "postpack": "mv package.json.bak package.json && rimraf ./unstable ./testing", + "prepack": "cp package.json package.json.bak && node ../../scripts/prepare-npm-package.js", + "postpack": "mv package.json.bak package.json", "i18n-extract": "i18next-cli extract --sync-primary" }, "devDependencies": { diff --git a/packages/grafana-alerting/rollup.config.ts b/packages/grafana-alerting/rollup.config.ts index f8f41e3ad2c..43914b47b70 100644 --- a/packages/grafana-alerting/rollup.config.ts +++ b/packages/grafana-alerting/rollup.config.ts @@ -9,19 +9,19 @@ export default [ { input: entryPoint, plugins, - output: [cjsOutput(pkg), esmOutput(pkg, 'grafana-alerting')], + output: [cjsOutput(pkg, 'grafana-alerting'), esmOutput(pkg, 'grafana-alerting')], treeshake: false, }, { input: 'src/unstable.ts', plugins, - output: [cjsOutput(pkg), esmOutput(pkg, 'grafana-alerting')], + output: [cjsOutput(pkg, 'grafana-alerting'), esmOutput(pkg, 'grafana-alerting')], treeshake: false, }, { input: 'src/testing.ts', plugins, - output: [cjsOutput(pkg), esmOutput(pkg, 'grafana-alerting')], + output: [cjsOutput(pkg, 'grafana-alerting'), esmOutput(pkg, 'grafana-alerting')], treeshake: false, }, ]; diff --git a/packages/grafana-api-clients/package.json b/packages/grafana-api-clients/package.json index 2d976b3a890..39accb04535 100644 --- a/packages/grafana-api-clients/package.json +++ b/packages/grafana-api-clients/package.json @@ -15,88 +15,121 @@ "url": "https://github.com/grafana/grafana.git", "directory": "packages/grafana-api-clients" }, - "main": "src/index.ts", - "module": "src/index.ts", - "types": "src/index.ts", + "main": "./dist/cjs/index.cjs", + "module": "./dist/esm/index.mjs", + "types": "./dist/types/index.d.ts", "exports": { "./package.json": "./package.json", ".": { - "import": "./src/index.ts", - "require": "./src/index.ts" + "@grafana-app/source": "./src/index.ts", + "types": "./dist/types/index.d.ts", + "import": "./dist/esm/index.mjs", + "require": "./dist/cjs/index.cjs" }, "./rtkq": { - "import": "./src/clients/rtkq/index.ts", - "require": "./src/clients/rtkq/index.ts" + "@grafana-app/source": "./src/clients/rtkq/index.ts", + "types": "./dist/types/clients/rtkq/index.d.ts", + "import": "./dist/esm/clients/rtkq/index.mjs", + "require": "./dist/cjs/clients/rtkq/index.cjs" }, "./rtkq/advisor/v0alpha1": { - "import": "./src/clients/rtkq/advisor/v0alpha1/index.ts", - "require": "./src/clients/rtkq/advisor/v0alpha1/index.ts" - }, - "./rtkq/correlations/v0alpha1": { - "import": "./src/clients/rtkq/correlations/v0alpha1/index.ts", - "require": "./src/clients/rtkq/correlations/v0alpha1/index.ts" - }, - "./rtkq/dashboard/v0alpha1": { - "import": "./src/clients/rtkq/dashboard/v0alpha1/index.ts", - "require": "./src/clients/rtkq/dashboard/v0alpha1/index.ts" - }, - "./rtkq/folder/v1beta1": { - "import": "./src/clients/rtkq/folder/v1beta1/index.ts", - "require": "./src/clients/rtkq/folder/v1beta1/index.ts" - }, - "./rtkq/iam/v0alpha1": { - "import": "./src/clients/rtkq/iam/v0alpha1/index.ts", - "require": "./src/clients/rtkq/iam/v0alpha1/index.ts" - }, - "./rtkq/legacy": { - "import": "./src/clients/rtkq/legacy/index.ts", - "require": "./src/clients/rtkq/legacy/index.ts" - }, - "./rtkq/legacy/migrate-to-cloud": { - "import": "./src/clients/rtkq/migrate-to-cloud/index.ts", - "require": "./src/clients/rtkq/migrate-to-cloud/index.ts" - }, - "./rtkq/legacy/preferences": { - "import": "./src/clients/rtkq/preferences/user/index.ts", - "require": "./src/clients/rtkq/preferences/user/index.ts" - }, - "./rtkq/legacy/user": { - "import": "./src/clients/rtkq/user/index.ts", - "require": "./src/clients/rtkq/user/index.ts" - }, - "./rtkq/playlist/v0alpha1": { - "import": "./src/clients/rtkq/playlist/v0alpha1/index.ts", - "require": "./src/clients/rtkq/playlist/v0alpha1/index.ts" - }, - "./rtkq/preferences/v1alpha1": { - "import": "./src/clients/rtkq/preferences/v1alpha1/index.ts", - "require": "./src/clients/rtkq/preferences/v1alpha1/index.ts" + "@grafana-app/source": "./src/clients/rtkq/advisor/v0alpha1/index.ts", + "types": "./dist/types/clients/rtkq/advisor/v0alpha1/index.d.ts", + "import": "./dist/esm/clients/rtkq/advisor/v0alpha1/index.mjs", + "require": "./dist/cjs/clients/rtkq/advisor/v0alpha1/index.cjs" }, "./rtkq/collections/v1alpha1": { - "import": "./src/clients/rtkq/collections/v1alpha1/index.ts", - "require": "./src/clients/rtkq/collections/v1alpha1/index.ts" + "@grafana-app/source": "./src/clients/rtkq/collections/v1alpha1/index.ts", + "types": "./dist/types/clients/rtkq/collections/v1alpha1/index.d.ts", + "import": "./dist/esm/clients/rtkq/collections/v1alpha1/index.mjs", + "require": "./dist/cjs/clients/rtkq/collections/v1alpha1/index.cjs" + }, + "./rtkq/correlations/v0alpha1": { + "@grafana-app/source": "./src/clients/rtkq/correlations/v0alpha1/index.ts", + "types": "./dist/types/clients/rtkq/correlations/v0alpha1/index.d.ts", + "import": "./dist/esm/clients/rtkq/correlations/v0alpha1/index.mjs", + "require": "./dist/cjs/clients/rtkq/correlations/v0alpha1/index.cjs" + }, + "./rtkq/dashboard/v0alpha1": { + "@grafana-app/source": "./src/clients/rtkq/dashboard/v0alpha1/index.ts", + "types": "./dist/types/clients/rtkq/dashboard/v0alpha1/index.d.ts", + "import": "./dist/esm/clients/rtkq/dashboard/v0alpha1/index.mjs", + "require": "./dist/cjs/clients/rtkq/dashboard/v0alpha1/index.cjs" + }, + "./rtkq/folder/v1beta1": { + "@grafana-app/source": "./src/clients/rtkq/folder/v1beta1/index.ts", + "types": "./dist/types/clients/rtkq/folder/v1beta1/index.d.ts", + "import": "./dist/esm/clients/rtkq/folder/v1beta1/index.mjs", + "require": "./dist/cjs/clients/rtkq/folder/v1beta1/index.cjs" + }, + "./rtkq/iam/v0alpha1": { + "@grafana-app/source": "./src/clients/rtkq/iam/v0alpha1/index.ts", + "types": "./dist/types/clients/rtkq/iam/v0alpha1/index.d.ts", + "import": "./dist/esm/clients/rtkq/iam/v0alpha1/index.mjs", + "require": "./dist/cjs/clients/rtkq/iam/v0alpha1/index.cjs" + }, + "./rtkq/legacy": { + "@grafana-app/source": "./src/clients/rtkq/legacy/index.ts", + "types": "./dist/types/clients/rtkq/legacy/index.d.ts", + "import": "./dist/esm/clients/rtkq/legacy/index.mjs", + "require": "./dist/cjs/clients/rtkq/legacy/index.cjs" + }, + "./rtkq/legacy/migrate-to-cloud": { + "@grafana-app/source": "./src/clients/rtkq/migrate-to-cloud/index.ts", + "types": "./dist/types/clients/rtkq/migrate-to-cloud/index.d.ts", + "import": "./dist/esm/clients/rtkq/migrate-to-cloud/index.mjs", + "require": "./dist/cjs/clients/rtkq/migrate-to-cloud/index.cjs" + }, + "./rtkq/legacy/preferences": { + "@grafana-app/source": "./src/clients/rtkq/preferences/user/index.ts", + "types": "./dist/types/clients/rtkq/preferences/user/index.d.ts", + "import": "./dist/esm/clients/rtkq/preferences/user/index.mjs", + "require": "./dist/cjs/clients/rtkq/preferences/user/index.cjs" + }, + "./rtkq/legacy/user": { + "@grafana-app/source": "./src/clients/rtkq/user/index.ts", + "types": "./dist/types/clients/rtkq/user/index.d.ts", + "import": "./dist/esm/clients/rtkq/user/index.mjs", + "require": "./dist/cjs/clients/rtkq/user/index.cjs" + }, + "./rtkq/playlist/v0alpha1": { + "@grafana-app/source": "./src/clients/rtkq/playlist/v0alpha1/index.ts", + "types": "./dist/types/clients/rtkq/playlist/v0alpha1/index.d.ts", + "import": "./dist/esm/clients/rtkq/playlist/v0alpha1/index.mjs", + "require": "./dist/cjs/clients/rtkq/playlist/v0alpha1/index.cjs" + }, + "./rtkq/preferences/v1alpha1": { + "@grafana-app/source": "./src/clients/rtkq/preferences/v1alpha1/index.ts", + "types": "./dist/types/clients/rtkq/preferences/v1alpha1/index.d.ts", + "import": "./dist/esm/clients/rtkq/preferences/v1alpha1/index.mjs", + "require": "./dist/cjs/clients/rtkq/preferences/v1alpha1/index.cjs" }, "./rtkq/provisioning/v0alpha1": { - "import": "./src/clients/rtkq/provisioning/v0alpha1/index.ts", - "require": "./src/clients/rtkq/provisioning/v0alpha1/index.ts" + "@grafana-app/source": "./src/clients/rtkq/provisioning/v0alpha1/index.ts", + "types": "./dist/types/clients/rtkq/provisioning/v0alpha1/index.d.ts", + "import": "./dist/esm/clients/rtkq/provisioning/v0alpha1/index.mjs", + "require": "./dist/cjs/clients/rtkq/provisioning/v0alpha1/index.cjs" }, "./rtkq/shorturl/v1beta1": { - "import": "./src/clients/rtkq/shorturl/v1beta1/index.ts", - "require": "./src/clients/rtkq/shorturl/v1beta1/index.ts" + "@grafana-app/source": "./src/clients/rtkq/shorturl/v1beta1/index.ts", + "types": "./dist/types/clients/rtkq/shorturl/v1beta1/index.d.ts", + "import": "./dist/esm/clients/rtkq/shorturl/v1beta1/index.mjs", + "require": "./dist/cjs/clients/rtkq/shorturl/v1beta1/index.cjs" }, "./rtkq/historian.alerting/v0alpha1": { - "import": "./src/clients/rtkq/historian.alerting/v0alpha1/index.ts", - "require": "./src/clients/rtkq/historian.alerting/v0alpha1/index.ts" + "@grafana-app/source": "./src/clients/rtkq/historian.alerting/v0alpha1/index.ts", + "types": "./dist/types/clients/rtkq/historian.alerting/v0alpha1/index.d.ts", + "import": "./dist/esm/clients/rtkq/historian.alerting/v0alpha1/index.mjs", + "require": "./dist/cjs/clients/rtkq/historian.alerting/v0alpha1/index.cjs" }, "./rtkq/logsdrilldown/v1alpha1": { - "import": "./src/clients/rtkq/logsdrilldown/v1alpha1/index.ts", - "require": "./src/clients/rtkq/logsdrilldown/v1alpha1/index.ts" + "@grafana-app/source": "./src/clients/rtkq/logsdrilldown/v1alpha1/index.ts", + "types": "./dist/types/clients/rtkq/logsdrilldown/v1alpha1/index.d.ts", + "import": "./dist/esm/clients/rtkq/logsdrilldown/v1alpha1/index.mjs", + "require": "./dist/cjs/clients/rtkq/logsdrilldown/v1alpha1/index.cjs" } }, "publishConfig": { - "main": "./dist/cjs/index.cjs", - "module": "./dist/esm/index.mjs", - "types": "./dist/types/index.d.ts", "access": "public" }, "files": [ diff --git a/packages/grafana-api-clients/rollup.config.ts b/packages/grafana-api-clients/rollup.config.ts index b36d59278fc..976a0c000fa 100644 --- a/packages/grafana-api-clients/rollup.config.ts +++ b/packages/grafana-api-clients/rollup.config.ts @@ -5,35 +5,17 @@ import { cjsOutput, entryPoint, esmOutput, plugins } from '../rollup.config.part const rq = createRequire(import.meta.url); const pkg = rq('./package.json'); -const apiClients = Object.entries<{ import: string; require: string }>(pkg.exports).filter(([key]) => - key.startsWith('./rtkq/') -); - -const apiClientConfigs = apiClients.map(([name, { import: importPath }]) => { - const baseCjsOutput = cjsOutput(pkg); - const entryFileNames = name.replace('./', '') + '.cjs'; - const cjsOutputConfig = { ...baseCjsOutput, entryFileNames }; - return { - input: importPath.replace('./', ''), - - plugins, - output: [cjsOutputConfig, esmOutput(pkg, 'grafana-api-clients')], - treeshake: false, - }; -}); - export default [ { input: entryPoint, plugins, - output: [cjsOutput(pkg), esmOutput(pkg, 'grafana-api-clients')], + output: [cjsOutput(pkg, 'grafana-api-clients'), esmOutput(pkg, 'grafana-api-clients')], treeshake: false, }, { input: 'src/clients/rtkq/index.ts', plugins, - output: [cjsOutput(pkg), esmOutput(pkg, 'grafana-api-clients')], + output: [cjsOutput(pkg, 'grafana-api-clients'), esmOutput(pkg, 'grafana-api-clients')], treeshake: false, }, - ...apiClientConfigs, ]; diff --git a/packages/grafana-api-clients/src/generator/helpers.ts b/packages/grafana-api-clients/src/generator/helpers.ts index 2ac2ce01389..daba285787c 100644 --- a/packages/grafana-api-clients/src/generator/helpers.ts +++ b/packages/grafana-api-clients/src/generator/helpers.ts @@ -143,8 +143,10 @@ export const updatePackageJsonExports = // Create the new export entry const newExportKey = `./rtkq/${groupName}/${version}`; const newExportValue = { - import: `./src/clients/rtkq/${groupName}/${version}/index.ts`, - require: `./src/clients/rtkq/${groupName}/${version}/index.ts`, + '@grafana-app/source': `./src/clients/rtkq/${groupName}/${version}/index.ts`, + types: `./dist/types/clients/rtkq/${groupName}/${version}/index.d.ts`, + import: `./dist/esm/clients/rtkq/${groupName}/${version}/index.mjs`, + require: `./dist/cjs/clients/rtkq/${groupName}/${version}/index.cjs`, }; // Check if export already exists diff --git a/packages/grafana-api-clients/tsconfig.json b/packages/grafana-api-clients/tsconfig.json index ed1c294703e..e75b58104b0 100644 --- a/packages/grafana-api-clients/tsconfig.json +++ b/packages/grafana-api-clients/tsconfig.json @@ -8,7 +8,8 @@ "emitDeclarationOnly": true, "isolatedModules": true, "rootDirs": ["."], - "allowImportingTsExtensions": true + "allowImportingTsExtensions": true, + "moduleResolution": "bundler" }, "exclude": ["dist/**/*"], "include": [ @@ -17,5 +18,12 @@ "../grafana-ui/src/types/*.d.ts", "../grafana-i18n/src/types/*.d.ts", "src/**/*.ts*" - ] + ], + "ts-node": { + "swc": true, + "compilerOptions": { + "module": "es2020", + "moduleResolution": "Bundler" + } + } } diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index ff90b2c295d..df595973cca 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -13,32 +13,31 @@ "url": "http://github.com/grafana/grafana.git", "directory": "packages/grafana-data" }, - "main": "src/index.ts", - "types": "src/index.ts", - "module": "src/index.ts", + "main": "./dist/cjs/index.cjs", + "module": "./dist/esm/index.mjs", + "types": "./dist/types/index.d.ts", "exports": { "./package.json": "./package.json", ".": { - "import": "./src/index.ts", - "require": "./src/index.ts" - }, - "./internal": { - "import": "./src/internal/index.ts", - "require": "./src/internal/index.ts" + "@grafana-app/source": "./src/index.ts", + "types": "./dist/types/index.d.ts", + "import": "./dist/esm/index.mjs", + "require": "./dist/cjs/index.cjs" }, "./unstable": { - "import": "./src/unstable.ts", - "require": "./src/unstable.ts" + "@grafana-app/source": "./src/unstable.ts", + "types": "./dist/types/unstable.d.ts", + "import": "./dist/esm/unstable.mjs", + "require": "./dist/cjs/unstable.cjs" + }, + "./internal": { + "@grafana-app/source": "./src/internal/index.ts" }, "./test": { - "import": "./test/index.ts", - "require": "./test/index.ts" + "@grafana-app/source": "./test/index.ts" } }, "publishConfig": { - "main": "./dist/cjs/index.cjs", - "module": "./dist/esm/index.mjs", - "types": "./dist/types/index.d.ts", "access": "public" }, "files": [ @@ -51,8 +50,8 @@ "build": "tsc -p ./tsconfig.build.json && rollup -c rollup.config.ts --configPlugin esbuild", "clean": "rimraf ./dist ./compiled ./unstable ./package.tgz", "typecheck": "tsc --emitDeclarationOnly false --noEmit", - "prepack": "cp package.json package.json.bak && ALIAS_PACKAGE_NAME=unstable node ../../scripts/prepare-npm-package.js", - "postpack": "mv package.json.bak package.json && rimraf ./unstable" + "prepack": "cp package.json package.json.bak && node ../../scripts/prepare-npm-package.js", + "postpack": "mv package.json.bak package.json" }, "dependencies": { "@braintree/sanitize-url": "7.0.1", diff --git a/packages/grafana-data/rollup.config.ts b/packages/grafana-data/rollup.config.ts index 378ab225769..87008ddc45f 100644 --- a/packages/grafana-data/rollup.config.ts +++ b/packages/grafana-data/rollup.config.ts @@ -9,13 +9,13 @@ export default [ { input: entryPoint, plugins, - output: [cjsOutput(pkg), esmOutput(pkg, 'grafana-data')], + output: [cjsOutput(pkg, 'grafana-data'), esmOutput(pkg, 'grafana-data')], treeshake: false, }, { input: 'src/unstable.ts', plugins, - output: [cjsOutput(pkg), esmOutput(pkg, 'grafana-data')], + output: [cjsOutput(pkg, 'grafana-data'), esmOutput(pkg, 'grafana-data')], treeshake: false, }, ]; diff --git a/packages/grafana-data/tsconfig.json b/packages/grafana-data/tsconfig.json index d13025d3115..8e6013e32d9 100644 --- a/packages/grafana-data/tsconfig.json +++ b/packages/grafana-data/tsconfig.json @@ -3,10 +3,12 @@ "compilerOptions": { "declaration": true, "jsx": "react-jsx", + "baseUrl": "./", "declarationDir": "./dist/types", "emitDeclarationOnly": true, "isolatedModules": true, - "rootDirs": ["."] + "rootDirs": ["."], + "moduleResolution": "bundler" }, "exclude": ["dist/**/*"], "include": [ diff --git a/packages/grafana-e2e-selectors/package.json b/packages/grafana-e2e-selectors/package.json index 0dd5c5a9925..5527f4c0ca6 100644 --- a/packages/grafana-e2e-selectors/package.json +++ b/packages/grafana-e2e-selectors/package.json @@ -16,12 +16,19 @@ "url": "http://github.com/grafana/grafana.git", "directory": "packages/grafana-e2e-selectors" }, - "main": "src/index.ts", - "types": "src/index.ts", + "main": "./dist/cjs/index.cjs", + "module": "./dist/esm/index.mjs", + "types": "./dist/types/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "@grafana-app/source": "./src/index.ts", + "types": "./dist/types/index.d.ts", + "import": "./dist/esm/index.mjs", + "require": "./dist/cjs/index.cjs" + } + }, "publishConfig": { - "main": "./dist/cjs/index.cjs", - "module": "./dist/esm/index.mjs", - "types": "./dist/types/index.d.ts", "access": "public" }, "files": [ diff --git a/packages/grafana-e2e-selectors/rollup.config.ts b/packages/grafana-e2e-selectors/rollup.config.ts index 1d47540a42a..a17799cc9a7 100644 --- a/packages/grafana-e2e-selectors/rollup.config.ts +++ b/packages/grafana-e2e-selectors/rollup.config.ts @@ -9,7 +9,7 @@ export default [ { input: entryPoint, plugins, - output: [cjsOutput(pkg), esmOutput(pkg, 'grafana-e2e-selectors')], + output: [cjsOutput(pkg, 'grafana-e2e-selectors'), esmOutput(pkg, 'grafana-e2e-selectors')], treeshake: false, }, ]; diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index 0755477f93b..de04398a3e1 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -1332,6 +1332,7 @@ export const versionedComponents = { }, DebugOverlay: { wrapper: { + '12.3.0': 'data-testid debug-overlay-wrapper', '9.2.0': 'debug-overlay', }, }, diff --git a/packages/grafana-e2e-selectors/tsconfig.json b/packages/grafana-e2e-selectors/tsconfig.json index 41f1fb8efb0..b3fd8756461 100644 --- a/packages/grafana-e2e-selectors/tsconfig.json +++ b/packages/grafana-e2e-selectors/tsconfig.json @@ -5,7 +5,8 @@ "declarationDir": "./dist/types", "emitDeclarationOnly": true, "isolatedModules": true, - "rootDirs": ["."] + "rootDirs": ["."], + "moduleResolution": "bundler" }, "exclude": ["dist/**/*"], "include": ["src/**/*.ts"] diff --git a/packages/grafana-flamegraph/package.json b/packages/grafana-flamegraph/package.json index f4ec11c7cfa..b1c16ed68d1 100644 --- a/packages/grafana-flamegraph/package.json +++ b/packages/grafana-flamegraph/package.json @@ -16,12 +16,19 @@ "url": "http://github.com/grafana/grafana.git", "directory": "packages/grafana-flamegraph" }, - "main": "src/index.ts", - "types": "src/index.ts", + "main": "./dist/cjs/index.cjs", + "module": "./dist/esm/index.mjs", + "types": "./dist/types/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "@grafana-app/source": "./src/index.ts", + "types": "./dist/types/index.d.ts", + "import": "./dist/esm/index.mjs", + "require": "./dist/cjs/index.cjs" + } + }, "publishConfig": { - "main": "./dist/cjs/index.cjs", - "module": "./dist/esm/index.mjs", - "types": "./dist/types/index.d.ts", "access": "public" }, "files": [ diff --git a/packages/grafana-flamegraph/rollup.config.ts b/packages/grafana-flamegraph/rollup.config.ts index 967916710d6..190cd7a6922 100644 --- a/packages/grafana-flamegraph/rollup.config.ts +++ b/packages/grafana-flamegraph/rollup.config.ts @@ -9,7 +9,7 @@ export default [ { input: entryPoint, plugins, - output: [cjsOutput(pkg), esmOutput(pkg, 'grafana-flamegraph')], + output: [cjsOutput(pkg, 'grafana-flamegraph'), esmOutput(pkg, 'grafana-flamegraph')], treeshake: false, }, ]; diff --git a/packages/grafana-flamegraph/tsconfig.json b/packages/grafana-flamegraph/tsconfig.json index 0a1330e2114..7d4e07d6534 100644 --- a/packages/grafana-flamegraph/tsconfig.json +++ b/packages/grafana-flamegraph/tsconfig.json @@ -7,7 +7,8 @@ "declarationDir": "./dist/types", "emitDeclarationOnly": true, "isolatedModules": true, - "rootDirs": ["."] + "rootDirs": ["."], + "moduleResolution": "bundler" }, "exclude": ["dist/**/*"], "include": ["src/**/*.ts*", "../../public/app/types/*.d.ts", "../grafana-ui/src/types/*.d.ts"] diff --git a/packages/grafana-i18n/package.json b/packages/grafana-i18n/package.json index 824f594f8e6..36a9faa541b 100644 --- a/packages/grafana-i18n/package.json +++ b/packages/grafana-i18n/package.json @@ -14,33 +14,32 @@ "url": "http://github.com/grafana/grafana.git", "directory": "packages/grafana-i18n" }, - "main": "src/index.ts", - "types": "src/index.ts", - "module": "src/index.ts", + "main": "./dist/cjs/index.cjs", + "module": "./dist/esm/index.mjs", + "types": "./dist/types/index.d.ts", "exports": { "./package.json": "./package.json", ".": { - "import": "./src/index.ts", - "require": "./src/index.ts" + "@grafana-app/source": "./src/index.ts", + "types": "./dist/types/index.d.ts", + "import": "./dist/esm/index.mjs", + "require": "./dist/cjs/index.cjs" }, "./internal": { - "import": "./src/internal/index.ts", - "require": "./src/internal/index.ts" + "@grafana-app/source": "./src/internal/index.ts" }, "./eslint-plugin": { + "@grafana-app/source": "./src/eslint/index.cjs", "types": "./src/eslint/index.d.ts", - "import": "./src/eslint/index.cjs", - "require": "./src/eslint/index.cjs" + "default": "./src/eslint/index.cjs" } }, "publishConfig": { - "main": "./dist/cjs/index.cjs", - "module": "./dist/esm/index.mjs", - "types": "./dist/types/index.d.ts", "access": "public" }, "files": [ "dist", + "src/eslint/**/*", "./README.md", "./CHANGELOG.md", "LICENSE_APACHE2" diff --git a/packages/grafana-i18n/rollup.config.ts b/packages/grafana-i18n/rollup.config.ts index 1b1115f65c9..913791e7d69 100644 --- a/packages/grafana-i18n/rollup.config.ts +++ b/packages/grafana-i18n/rollup.config.ts @@ -1,5 +1,4 @@ import { createRequire } from 'node:module'; -import copy from 'rollup-plugin-copy'; import { entryPoint, plugins, esmOutput, cjsOutput } from '../rollup.config.parts'; @@ -9,13 +8,8 @@ const pkg = rq('./package.json'); export default [ { input: entryPoint, - plugins: [ - ...plugins, - copy({ - targets: [{ src: 'src/eslint', dest: 'dist' }], - }), - ], - output: [cjsOutput(pkg), esmOutput(pkg, 'grafana-i18n')], + plugins, + output: [cjsOutput(pkg, 'grafana-i18n'), esmOutput(pkg, 'grafana-i18n')], treeshake: false, }, ]; diff --git a/packages/grafana-i18n/tsconfig.json b/packages/grafana-i18n/tsconfig.json index 2c83128f0f1..b1340cd69d0 100644 --- a/packages/grafana-i18n/tsconfig.json +++ b/packages/grafana-i18n/tsconfig.json @@ -6,7 +6,8 @@ "declarationDir": "./dist/types", "emitDeclarationOnly": true, "isolatedModules": true, - "rootDirs": ["."] + "rootDirs": ["."], + "moduleResolution": "bundler" }, "exclude": ["dist/**/*"], "include": ["src/**/*.ts*"] diff --git a/packages/grafana-o11y-ds-frontend/tsconfig.json b/packages/grafana-o11y-ds-frontend/tsconfig.json index c77d4f035c9..1817cc219ce 100644 --- a/packages/grafana-o11y-ds-frontend/tsconfig.json +++ b/packages/grafana-o11y-ds-frontend/tsconfig.json @@ -8,7 +8,8 @@ "emitDeclarationOnly": true, "isolatedModules": true, "allowJs": true, - "rootDirs": ["."] + "rootDirs": ["."], + "moduleResolution": "bundler" }, "exclude": ["dist/**/*"], "include": [ diff --git a/packages/grafana-plugin-configs/jest/jest.config.js b/packages/grafana-plugin-configs/jest/jest.config.js index 1f87fd10556..05e5a701226 100644 --- a/packages/grafana-plugin-configs/jest/jest.config.js +++ b/packages/grafana-plugin-configs/jest/jest.config.js @@ -17,6 +17,9 @@ export default { setupFiles: ['jest-canvas-mock'], setupFilesAfterEnv: ['/jest-setup.js'], testEnvironment: 'jsdom', + testEnvironmentOptions: { + customExportConditions: ['@grafana-app/source', 'browser'], + }, testMatch: ['/**/__tests__/**/*.{js,jsx,ts,tsx}', '/**/*.{spec,test,jest}.{js,jsx,ts,tsx}'], transform: { '^.+\\.(t|j)sx?$': [ diff --git a/packages/grafana-plugin-configs/tsconfig.json b/packages/grafana-plugin-configs/tsconfig.json index b7a0dd239e8..db619d2b1c2 100644 --- a/packages/grafana-plugin-configs/tsconfig.json +++ b/packages/grafana-plugin-configs/tsconfig.json @@ -1,12 +1,13 @@ { "compilerOptions": { - "jsx": "react-jsx", + "allowImportingTsExtensions": true, "alwaysStrict": true, + "customConditions": ["@grafana-app/source"], "declaration": false, - "resolveJsonModule": true, + "jsx": "react-jsx", "moduleResolution": "bundler", "noEmit": true, - "allowImportingTsExtensions": true + "resolveJsonModule": true }, "extends": "@grafana/tsconfig", "exclude": ["**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx"], diff --git a/packages/grafana-plugin-configs/webpack.config.ts b/packages/grafana-plugin-configs/webpack.config.ts index 6f2846b5a97..8bfcdcec69a 100644 --- a/packages/grafana-plugin-configs/webpack.config.ts +++ b/packages/grafana-plugin-configs/webpack.config.ts @@ -312,6 +312,7 @@ const config = async (env: Env): Promise => { resolve: { extensions: ['.ts', '.tsx', '.js', '.jsx'], + conditionNames: ['@grafana-app/source', '...'], unsafeCache: true, }, diff --git a/packages/grafana-prometheus/package.json b/packages/grafana-prometheus/package.json index 8ed4bd95d5a..6d6fa062635 100644 --- a/packages/grafana-prometheus/package.json +++ b/packages/grafana-prometheus/package.json @@ -15,8 +15,18 @@ "url": "http://github.com/grafana/grafana.git", "directory": "packages/grafana-prometheus" }, - "main": "src/index.ts", - "types": "src/index.ts", + "main": "./dist/cjs/index.cjs", + "module": "./dist/esm/index.mjs", + "types": "./dist/types/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "@grafana-app/source": "./src/index.ts", + "types": "./dist/types/index.d.ts", + "import": "./dist/esm/index.mjs", + "require": "./dist/cjs/index.cjs" + } + }, "files": [ "./dist", "./README.md", @@ -24,9 +34,6 @@ "./LICENSE_AGPL" ], "publishConfig": { - "main": "./dist/cjs/index.cjs", - "module": "./dist/esm/index.mjs", - "types": "./dist/types/index.d.ts", "access": "public" }, "scripts": { diff --git a/packages/grafana-prometheus/rollup.config.ts b/packages/grafana-prometheus/rollup.config.ts index 8dbc60c8935..28083b2d26f 100644 --- a/packages/grafana-prometheus/rollup.config.ts +++ b/packages/grafana-prometheus/rollup.config.ts @@ -12,7 +12,7 @@ export default [ { input: entryPoint, plugins: [...plugins, image(), json(), dynamicImportVars()], - output: [cjsOutput(pkg), esmOutput(pkg, 'grafana-prometheus')], + output: [cjsOutput(pkg, 'grafana-prometheus'), esmOutput(pkg, 'grafana-prometheus')], treeshake: false, }, ]; diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index a46d08d4623..acf3f60bbc3 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -14,28 +14,28 @@ "url": "http://github.com/grafana/grafana.git", "directory": "packages/grafana-runtime" }, - "main": "src/index.ts", - "types": "src/index.ts", - "module": "src/index.ts", + "main": "./dist/cjs/index.cjs", + "module": "./dist/esm/index.mjs", + "types": "./dist/types/index.d.ts", "exports": { "./package.json": "./package.json", ".": { - "import": "./src/index.ts", - "require": "./src/index.ts" - }, - "./internal": { - "import": "./src/internal/index.ts", - "require": "./src/internal/index.ts" + "@grafana-app/source": "./src/index.ts", + "types": "./dist/types/index.d.ts", + "import": "./dist/esm/index.mjs", + "require": "./dist/cjs/index.cjs" }, "./unstable": { - "import": "./src/unstable.ts", - "require": "./src/unstable.ts" + "@grafana-app/source": "./src/unstable.ts", + "types": "./dist/types/unstable.d.ts", + "import": "./dist/esm/unstable.mjs", + "require": "./dist/cjs/unstable.cjs" + }, + "./internal": { + "@grafana-app/source": "./src/internal/index.ts" } }, "publishConfig": { - "main": "./dist/cjs/index.cjs", - "module": "./dist/esm/index.mjs", - "types": "./dist/types/index.d.ts", "access": "public" }, "files": [ @@ -49,8 +49,8 @@ "bundle": "rollup -c rollup.config.ts --configPlugin esbuild", "clean": "rimraf ./dist ./compiled ./unstable ./package.tgz", "typecheck": "tsc --emitDeclarationOnly false --noEmit", - "prepack": "cp package.json package.json.bak && ALIAS_PACKAGE_NAME=unstable node ../../scripts/prepare-npm-package.js", - "postpack": "mv package.json.bak package.json && rimraf ./unstable" + "prepack": "cp package.json package.json.bak && node ../../scripts/prepare-npm-package.js", + "postpack": "mv package.json.bak package.json" }, "dependencies": { "@grafana/data": "12.4.0-pre", diff --git a/packages/grafana-runtime/rollup.config.ts b/packages/grafana-runtime/rollup.config.ts index 5807c919136..120ad98645d 100644 --- a/packages/grafana-runtime/rollup.config.ts +++ b/packages/grafana-runtime/rollup.config.ts @@ -9,13 +9,13 @@ export default [ { input: entryPoint, plugins, - output: [cjsOutput(pkg), esmOutput(pkg, 'grafana-runtime')], + output: [cjsOutput(pkg, 'grafana-runtime'), esmOutput(pkg, 'grafana-runtime')], treeshake: false, }, { input: 'src/unstable.ts', plugins, - output: [cjsOutput(pkg), esmOutput(pkg, 'grafana-runtime')], + output: [cjsOutput(pkg, 'grafana-runtime'), esmOutput(pkg, 'grafana-runtime')], treeshake: false, }, ]; diff --git a/packages/grafana-runtime/src/index.ts b/packages/grafana-runtime/src/index.ts index 489f9157700..58b30be8542 100644 --- a/packages/grafana-runtime/src/index.ts +++ b/packages/grafana-runtime/src/index.ts @@ -9,7 +9,15 @@ export * from './analytics/types'; export { loadPluginCss, type PluginCssOptions, setPluginImportUtils, getPluginImportUtils } from './utils/plugin'; export { reportMetaAnalytics, reportInteraction, reportPageview, reportExperimentView } from './analytics/utils'; export { featureEnabled } from './utils/licensing'; -export { logInfo, logDebug, logWarning, logError, createMonitoringLogger, logMeasurement } from './utils/logging'; +export { + logInfo, + logDebug, + logWarning, + logError, + createMonitoringLogger, + logMeasurement, + type MonitoringLogger, +} from './utils/logging'; export { DataSourceWithBackend, HealthCheckError, diff --git a/packages/grafana-runtime/tsconfig.json b/packages/grafana-runtime/tsconfig.json index 9dec8dd53ed..bbfc33b77ed 100644 --- a/packages/grafana-runtime/tsconfig.json +++ b/packages/grafana-runtime/tsconfig.json @@ -8,7 +8,8 @@ "emitDeclarationOnly": true, "isolatedModules": true, "allowJs": true, - "rootDirs": ["."] + "rootDirs": ["."], + "moduleResolution": "bundler" }, "exclude": ["dist/**/*"], "include": [ diff --git a/packages/grafana-schema/package.json b/packages/grafana-schema/package.json index 067475cd209..4f48e1511b2 100644 --- a/packages/grafana-schema/package.json +++ b/packages/grafana-schema/package.json @@ -13,13 +13,14 @@ "url": "http://github.com/grafana/grafana.git", "directory": "packages/grafana-schema" }, - "main": "src/index.ts", - "types": "src/index.ts", + "main": "./src/index.ts", + "module": "./src/index.ts", + "types": "./src/index.ts", "publishConfig": { + "access": "public", "main": "./dist/cjs/index.cjs", "module": "./dist/esm/index.mjs", - "types": "./dist/types/index.d.ts", - "access": "public" + "types": "./dist/types/index.d.ts" }, "files": [ "dist", diff --git a/packages/grafana-schema/rollup.config.ts b/packages/grafana-schema/rollup.config.ts index 04928f75c47..4e5f77b3deb 100644 --- a/packages/grafana-schema/rollup.config.ts +++ b/packages/grafana-schema/rollup.config.ts @@ -15,7 +15,12 @@ export default [ { input: entryPoint, plugins, - output: [cjsOutput(pkg), esmOutput(pkg, 'grafana-schema')], + output: [ + // Schema still uses publishConfig to define output directory. + // TODO: Migrate this package to use exports. + cjsOutput(pkg, 'grafana-schema', { dir: path.dirname(pkg.publishConfig.main) }), + esmOutput(pkg, 'grafana-schema', { dir: path.dirname(pkg.publishConfig.module) }), + ], treeshake: false, }, { diff --git a/packages/grafana-schema/tsconfig.json b/packages/grafana-schema/tsconfig.json index 2c83128f0f1..8012f3267e6 100644 --- a/packages/grafana-schema/tsconfig.json +++ b/packages/grafana-schema/tsconfig.json @@ -3,10 +3,12 @@ "compilerOptions": { "declaration": true, "jsx": "react-jsx", + "baseUrl": "./", "declarationDir": "./dist/types", "emitDeclarationOnly": true, "isolatedModules": true, - "rootDirs": ["."] + "rootDirs": ["."], + "moduleResolution": "bundler" }, "exclude": ["dist/**/*"], "include": ["src/**/*.ts*"] diff --git a/packages/grafana-sql/src/utils/logging.ts b/packages/grafana-sql/src/utils/logging.ts index 95900184359..2d481dc4b8a 100644 --- a/packages/grafana-sql/src/utils/logging.ts +++ b/packages/grafana-sql/src/utils/logging.ts @@ -1,3 +1,3 @@ -import { createMonitoringLogger } from '@grafana/runtime'; +import { createMonitoringLogger, MonitoringLogger } from '@grafana/runtime'; -export const sqlPluginLogger = createMonitoringLogger('features.plugins.sql'); +export const sqlPluginLogger: MonitoringLogger = createMonitoringLogger('features.plugins.sql'); diff --git a/packages/grafana-sql/tsconfig.json b/packages/grafana-sql/tsconfig.json index 4bd2161f0e0..87f6db544ad 100644 --- a/packages/grafana-sql/tsconfig.json +++ b/packages/grafana-sql/tsconfig.json @@ -8,7 +8,8 @@ "emitDeclarationOnly": true, "isolatedModules": true, "strict": true, - "rootDirs": ["."] + "rootDirs": ["."], + "moduleResolution": "bundler" }, "exclude": ["dist/**/*"], "include": ["src/**/*.ts*", "../../public/app/types/*.d.ts", "../grafana-ui/src/types/*.d.ts"] diff --git a/packages/grafana-ui/.storybook/main.ts b/packages/grafana-ui/.storybook/main.ts index 29258c4a228..9bbf711b5c2 100644 --- a/packages/grafana-ui/.storybook/main.ts +++ b/packages/grafana-ui/.storybook/main.ts @@ -95,6 +95,16 @@ const mainConfig: StorybookConfig = { }, }); + // Tell storybook to resolve imports with the @grafana-app/source condition for + // the packages in this repo. + if (config && config.resolve) { + if (Array.isArray(config.resolve.conditionNames)) { + config.resolve.conditionNames.unshift('@grafana-app/source'); + } else { + config.resolve.conditionNames = ['@grafana-app/source', '...']; + } + } + return config; }, }; diff --git a/packages/grafana-ui/.storybook/tsconfig.json b/packages/grafana-ui/.storybook/tsconfig.json index cbcee5966e1..9d5c974f558 100644 --- a/packages/grafana-ui/.storybook/tsconfig.json +++ b/packages/grafana-ui/.storybook/tsconfig.json @@ -1,8 +1,7 @@ { "compilerOptions": { "declarationDir": "dist", - "noUnusedLocals": false, - "outDir": "compiled" + "noUnusedLocals": false }, "extends": "../tsconfig.json", "include": ["../src/**/*.ts*", "../../../public/app/types/svg.d.ts"] diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index f4a358d1654..478c44704ed 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -16,28 +16,28 @@ "url": "http://github.com/grafana/grafana.git", "directory": "packages/grafana-ui" }, - "main": "src/index.ts", - "types": "src/index.ts", - "module": "src/index.ts", + "main": "./dist/cjs/index.cjs", + "module": "./dist/esm/index.mjs", + "types": "./dist/types/index.d.ts", "exports": { "./package.json": "./package.json", ".": { - "import": "./src/index.ts", - "require": "./src/index.ts" - }, - "./internal": { - "import": "./src/internal/index.ts", - "require": "./src/internal/index.ts" + "@grafana-app/source": "./src/index.ts", + "types": "./dist/types/index.d.ts", + "import": "./dist/esm/index.mjs", + "require": "./dist/cjs/index.cjs" }, "./unstable": { - "import": "./src/unstable.ts", - "require": "./src/unstable.ts" + "@grafana-app/source": "./src/unstable.ts", + "types": "./dist/types/unstable.d.ts", + "import": "./dist/esm/unstable.mjs", + "require": "./dist/cjs/unstable.cjs" + }, + "./internal": { + "@grafana-app/source": "./src/internal/index.ts" } }, "publishConfig": { - "main": "./dist/cjs/index.cjs", - "module": "./dist/esm/index.mjs", - "types": "./dist/types/index.d.ts", "access": "public" }, "files": [ @@ -55,8 +55,8 @@ "storybook:build": "storybook build -o ./dist/storybook -c .storybook", "storybook:test": "test-storybook --url http://localhost:9001", "typecheck": "tsc --emitDeclarationOnly false --noEmit", - "prepack": "cp package.json package.json.bak && ALIAS_PACKAGE_NAME=unstable node ../../scripts/prepare-npm-package.js", - "postpack": "mv package.json.bak package.json && rimraf ./unstable" + "prepack": "cp package.json package.json.bak && node ../../scripts/prepare-npm-package.js", + "postpack": "mv package.json.bak package.json" }, "browserslist": [ "defaults", diff --git a/packages/grafana-ui/rollup.config.ts b/packages/grafana-ui/rollup.config.ts index 16c81ca4af8..14ff11d279b 100644 --- a/packages/grafana-ui/rollup.config.ts +++ b/packages/grafana-ui/rollup.config.ts @@ -24,7 +24,7 @@ export default [ flatten: false, }), ], - output: [cjsOutput(pkg), esmOutput(pkg, 'grafana-ui')], + output: [cjsOutput(pkg, 'grafana-ui'), esmOutput(pkg, 'grafana-ui')], treeshake: false, }, { @@ -37,7 +37,7 @@ export default [ flatten: false, }), ], - output: [cjsOutput(pkg), esmOutput(pkg, 'grafana-ui')], + output: [cjsOutput(pkg, 'grafana-ui'), esmOutput(pkg, 'grafana-ui')], treeshake: false, }, ]; diff --git a/packages/grafana-ui/src/components/Forms/Legacy/Select/SelectOptionGroup.tsx b/packages/grafana-ui/src/components/Forms/Legacy/Select/SelectOptionGroup.tsx index aecc7bf4514..a249db3eb2e 100644 --- a/packages/grafana-ui/src/components/Forms/Legacy/Select/SelectOptionGroup.tsx +++ b/packages/grafana-ui/src/components/Forms/Legacy/Select/SelectOptionGroup.tsx @@ -94,4 +94,5 @@ class UnthemedSelectOptionGroup extends PureComponent } } -export const SelectOptionGroup = withTheme2(UnthemedSelectOptionGroup); +// TODO: type this properly +export const SelectOptionGroup: React.FC = withTheme2(UnthemedSelectOptionGroup); diff --git a/packages/grafana-ui/src/components/Select/ValueContainer.tsx b/packages/grafana-ui/src/components/Select/ValueContainer.tsx index 6eb8326ae5d..f39666c8c5f 100644 --- a/packages/grafana-ui/src/components/Select/ValueContainer.tsx +++ b/packages/grafana-ui/src/components/Select/ValueContainer.tsx @@ -76,4 +76,5 @@ class UnthemedValueContainer>> = + withTheme2(UnthemedValueContainer); diff --git a/packages/grafana-ui/tsconfig.json b/packages/grafana-ui/tsconfig.json index 502d1958513..95871844f67 100644 --- a/packages/grafana-ui/tsconfig.json +++ b/packages/grafana-ui/tsconfig.json @@ -8,7 +8,8 @@ "emitDeclarationOnly": true, "isolatedModules": true, "allowJs": true, - "rootDirs": ["."] + "rootDirs": ["."], + "moduleResolution": "bundler" }, "exclude": ["dist/**/*"], "include": ["../../public/test/setupTests.ts", "../../public/app/types/*.d.ts", "src/**/*.ts*"], diff --git a/packages/rollup.config.parts.ts b/packages/rollup.config.parts.ts index 31116f56bdb..534b8cd7d25 100644 --- a/packages/rollup.config.parts.ts +++ b/packages/rollup.config.parts.ts @@ -23,25 +23,29 @@ export const plugins = [ ]; // Generates a rollup configuration for commonjs output. -export function cjsOutput(pkg) { +export function cjsOutput(pkg, pkgName, overrides = {}) { return { format: 'cjs', sourcemap: true, - dir: dirname(pkg.publishConfig.main), + dir: dirname(pkg.main), entryFileNames: '[name].cjs', + preserveModules: true, + preserveModulesRoot: resolve(projectCwd, `packages/${pkgName}/src`), esModule: true, interop: 'compat', + ...overrides, }; } // Generate a rollup configuration for es module output. -export function esmOutput(pkg, pkgName) { +export function esmOutput(pkg, pkgName, overrides = {}) { return { format: 'esm', sourcemap: true, - dir: dirname(pkg.publishConfig.module), + dir: dirname(pkg.module), entryFileNames: '[name].mjs', preserveModules: true, preserveModulesRoot: resolve(projectCwd, `packages/${pkgName}/src`), + ...overrides, }; } diff --git a/project.json b/project.json index a4a3fa81c09..702efba58af 100644 --- a/project.json +++ b/project.json @@ -39,7 +39,8 @@ "inputs": [ "{workspaceRoot}/scripts/cli/generateSassVariableFiles.ts", "{workspaceRoot}/packages/grafana-data/src/themes/**", - "{workspaceRoot}/packages/grafana-ui/src/themes/**" + "{workspaceRoot}/packages/grafana-ui/src/themes/**", + "{workspaceRoot}/package.json" ], "outputs": [ "{workspaceRoot}/public/sass/_variables.generated.scss", diff --git a/public/app/features/admin/UserListAdminPage.tsx b/public/app/features/admin/UserListAdminPage.tsx index cd7695b1db8..feebb6b98e1 100644 --- a/public/app/features/admin/UserListAdminPage.tsx +++ b/public/app/features/admin/UserListAdminPage.tsx @@ -3,7 +3,7 @@ import { ComponentType, useEffect } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { GrafanaTheme2 } from '@grafana/data'; -import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { LinkButton, RadioButtonGroup, useStyles2, FilterInput, EmptyState } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; diff --git a/public/app/features/admin/UserListAnonymousPage.tsx b/public/app/features/admin/UserListAnonymousPage.tsx index eec26483830..bfb1f6f9125 100644 --- a/public/app/features/admin/UserListAnonymousPage.tsx +++ b/public/app/features/admin/UserListAnonymousPage.tsx @@ -3,7 +3,7 @@ import { useEffect } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { GrafanaTheme2 } from '@grafana/data'; -import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { t } from '@grafana/i18n'; import { RadioButtonGroup, useStyles2, FilterInput } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; diff --git a/public/app/features/admin/UserListPage.test.tsx b/public/app/features/admin/UserListPage.test.tsx index 6601c06827d..4df8161b70c 100644 --- a/public/app/features/admin/UserListPage.test.tsx +++ b/public/app/features/admin/UserListPage.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { GrafanaBootConfig } from '@grafana/runtime'; import config from 'app/core/config'; diff --git a/public/app/features/admin/UserListPublicDashboardPage/DashboardsListModalButton.tsx b/public/app/features/admin/UserListPublicDashboardPage/DashboardsListModalButton.tsx index af1eda1f2fd..bf384a83bbc 100644 --- a/public/app/features/admin/UserListPublicDashboardPage/DashboardsListModalButton.tsx +++ b/public/app/features/admin/UserListPublicDashboardPage/DashboardsListModalButton.tsx @@ -1,7 +1,7 @@ import { css, cx } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; -import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { Button, LoadingPlaceholder, Modal, ModalsController, useStyles2 } from '@grafana/ui'; import { diff --git a/public/app/features/admin/UserListPublicDashboardPage/UserListPublicDashboardPage.tsx b/public/app/features/admin/UserListPublicDashboardPage/UserListPublicDashboardPage.tsx index c3618ba9b67..c18cb9c5c15 100644 --- a/public/app/features/admin/UserListPublicDashboardPage/UserListPublicDashboardPage.tsx +++ b/public/app/features/admin/UserListPublicDashboardPage/UserListPublicDashboardPage.tsx @@ -1,4 +1,4 @@ -import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { Icon, Stack, Tag, Tooltip } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; diff --git a/public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx b/public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx index 648f6de57ef..4fd9bc8b9a6 100644 --- a/public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx +++ b/public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx @@ -16,7 +16,7 @@ import { sceneUtils, VizPanel, } from '@grafana/scenes'; -import { LibraryPanel } from '@grafana/schema/'; +import { LibraryPanel } from '@grafana/schema'; import { Alert, Button, CodeEditor, Field, Select, useStyles2 } from '@grafana/ui'; import { isDashboardV2Spec } from 'app/features/dashboard/api/utils'; import { getPanelDataFrames } from 'app/features/dashboard/components/HelpWizard/utils'; diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx index ee7fd012add..15b7e82ae36 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx @@ -1,7 +1,7 @@ import { css, cx } from '@emotion/css'; import { memo, useMemo } from 'react'; -import { GrafanaTheme2 } from '@grafana/data/'; +import { GrafanaTheme2 } from '@grafana/data'; import { LazyLoader, SceneComponentProps, VizPanel } from '@grafana/scenes'; import { useStyles2 } from '@grafana/ui'; diff --git a/public/app/features/dashboard/components/DashNav/ShareButton.tsx b/public/app/features/dashboard/components/DashNav/ShareButton.tsx index 1908ea1612a..a0e5200f47a 100644 --- a/public/app/features/dashboard/components/DashNav/ShareButton.tsx +++ b/public/app/features/dashboard/components/DashNav/ShareButton.tsx @@ -1,4 +1,4 @@ -import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { Trans } from '@grafana/i18n'; import { locationService } from '@grafana/runtime'; import { Button } from '@grafana/ui'; diff --git a/public/app/features/dashboard/components/PublicDashboardNotAvailable/PublicDashboardNotAvailable.tsx b/public/app/features/dashboard/components/PublicDashboardNotAvailable/PublicDashboardNotAvailable.tsx index 1ab0b57a937..ffa019d20d3 100644 --- a/public/app/features/dashboard/components/PublicDashboardNotAvailable/PublicDashboardNotAvailable.tsx +++ b/public/app/features/dashboard/components/PublicDashboardNotAvailable/PublicDashboardNotAvailable.tsx @@ -1,7 +1,7 @@ import { css, cx } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; -import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { useStyles2 } from '@grafana/ui'; diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/ConfigPublicDashboard.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/ConfigPublicDashboard.tsx index be257dc5042..9d3dcb4a78b 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/ConfigPublicDashboard.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/ConfigPublicDashboard.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import { useForm } from 'react-hook-form'; import { GrafanaTheme2, TimeRange } from '@grafana/data'; -import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { Button, ClipboardButton, Field, Input, Stack, Label, ModalsController, Switch, useStyles2 } from '@grafana/ui'; import { diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/Configuration.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/Configuration.tsx index 6cb97b9ac47..c0018735f4b 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/Configuration.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/Configuration.tsx @@ -1,7 +1,7 @@ import { UseFormRegister } from 'react-hook-form'; import { TimeRange } from '@grafana/data'; -import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { FieldSet, Label, Switch, TimeRangeInput, Stack } from '@grafana/ui'; import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions'; diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/CreatePublicDashboard/AcknowledgeCheckboxes.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/CreatePublicDashboard/AcknowledgeCheckboxes.tsx index 76bed8c8d94..c875e3a7d90 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/CreatePublicDashboard/AcknowledgeCheckboxes.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/CreatePublicDashboard/AcknowledgeCheckboxes.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import { UseFormRegister } from 'react-hook-form'; import { GrafanaTheme2 } from '@grafana/data'; -import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { Checkbox, FieldSet, LinkButton, useStyles2, Stack } from '@grafana/ui'; diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/NoUpsertPermissionsAlert.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/NoUpsertPermissionsAlert.tsx index a480fdd918f..4af3ade3397 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/NoUpsertPermissionsAlert.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/NoUpsertPermissionsAlert.tsx @@ -1,4 +1,4 @@ -import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { Alert } from '@grafana/ui'; diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedDataSourcesAlert.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedDataSourcesAlert.tsx index cc000e869ab..d0ac5015bfe 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedDataSourcesAlert.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedDataSourcesAlert.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import cx from 'classnames'; import { GrafanaTheme2 } from '@grafana/data'; -import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { Alert, useStyles2 } from '@grafana/ui'; diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedTemplateVariablesAlert.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedTemplateVariablesAlert.tsx index 3eb2c3d0904..6ce2607fb13 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedTemplateVariablesAlert.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedTemplateVariablesAlert.tsx @@ -1,4 +1,4 @@ -import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { Alert } from '@grafana/ui'; diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx index 9a0357f0626..99da08f24f8 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx @@ -4,7 +4,7 @@ import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import { BootData, DataQuery } from '@grafana/data'; -import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { reportInteraction, setEchoSrv } from '@grafana/runtime'; import { Panel } from '@grafana/schema'; import config from 'app/core/config'; diff --git a/public/app/features/dashboard/containers/PublicDashboardPage.test.tsx b/public/app/features/dashboard/containers/PublicDashboardPage.test.tsx index 4500526802a..222f334ee4d 100644 --- a/public/app/features/dashboard/containers/PublicDashboardPage.test.tsx +++ b/public/app/features/dashboard/containers/PublicDashboardPage.test.tsx @@ -5,7 +5,7 @@ import { useEffectOnce } from 'react-use'; import { Props as AutoSizerProps } from 'react-virtualized-auto-sizer'; import { render } from 'test/test-utils'; -import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { Dashboard, DashboardCursorSync, FieldConfigSource, Panel, ThresholdsMode } from '@grafana/schema/src'; import { getRouteComponentProps } from 'app/core/navigation/mocks/routeProps'; import { DashboardInitPhase, DashboardMeta, DashboardRoutes } from 'app/types/dashboard'; diff --git a/public/app/features/dashboard/containers/PublicDashboardPage.tsx b/public/app/features/dashboard/containers/PublicDashboardPage.tsx index b6f74800a76..98553e46bc1 100644 --- a/public/app/features/dashboard/containers/PublicDashboardPage.tsx +++ b/public/app/features/dashboard/containers/PublicDashboardPage.tsx @@ -4,7 +4,7 @@ import { useLocation, useParams } from 'react-router-dom-v5-compat'; import { usePrevious } from 'react-use'; import { GrafanaTheme2, PageLayoutType, TimeZone } from '@grafana/data'; -import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { PageToolbar, useStyles2 } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { useGrafana } from 'app/core/context/GrafanaContext'; diff --git a/public/app/features/explore/PrometheusListView/RawListItem.tsx b/public/app/features/explore/PrometheusListView/RawListItem.tsx index 4da433e89ce..d192b5da3e9 100644 --- a/public/app/features/explore/PrometheusListView/RawListItem.tsx +++ b/public/app/features/explore/PrometheusListView/RawListItem.tsx @@ -3,7 +3,7 @@ import { useCopyToClipboard } from 'react-use'; import { Field, GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { isValidLegacyName, utf8Support } from '@grafana/prometheus/src/utf8_support'; +import { isValidLegacyName, utf8Support } from '@grafana/prometheus'; import { reportInteraction } from '@grafana/runtime'; import { IconButton, useStyles2 } from '@grafana/ui'; diff --git a/public/app/features/logs/components/panel/LogListControls.tsx b/public/app/features/logs/components/panel/LogListControls.tsx index 5b482af2aad..a5deb524d66 100644 --- a/public/app/features/logs/components/panel/LogListControls.tsx +++ b/public/app/features/logs/components/panel/LogListControls.tsx @@ -5,13 +5,13 @@ import { MouseEvent, useCallback, useMemo } from 'react'; import { CoreApp, EventBus, + GrafanaTheme2, LogLevel, LogsDedupDescription, LogsDedupStrategy, LogsSortOrder, store, } from '@grafana/data'; -import { GrafanaTheme2 } from '@grafana/data/'; import { t } from '@grafana/i18n'; import { config, reportInteraction } from '@grafana/runtime'; import { Dropdown, Menu, useStyles2 } from '@grafana/ui'; diff --git a/public/app/features/manage-dashboards/components/PublicDashboardListTable/PublicDashboardListTable.test.tsx b/public/app/features/manage-dashboards/components/PublicDashboardListTable/PublicDashboardListTable.test.tsx index f5d3ad028b0..0014f107004 100644 --- a/public/app/features/manage-dashboards/components/PublicDashboardListTable/PublicDashboardListTable.test.tsx +++ b/public/app/features/manage-dashboards/components/PublicDashboardListTable/PublicDashboardListTable.test.tsx @@ -3,7 +3,7 @@ import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import { render } from 'test/test-utils'; -import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { backendSrv } from 'app/core/services/backend_srv'; import { contextSrv } from 'app/core/services/context_srv'; diff --git a/public/app/features/manage-dashboards/components/PublicDashboardListTable/PublicDashboardListTable.tsx b/public/app/features/manage-dashboards/components/PublicDashboardListTable/PublicDashboardListTable.tsx index d14fd970d1d..a1bcedb5e70 100644 --- a/public/app/features/manage-dashboards/components/PublicDashboardListTable/PublicDashboardListTable.tsx +++ b/public/app/features/manage-dashboards/components/PublicDashboardListTable/PublicDashboardListTable.tsx @@ -3,7 +3,7 @@ import { useMemo, useState } from 'react'; import { useMedia } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; -import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { reportInteraction } from '@grafana/runtime'; import { diff --git a/public/app/plugins/panel/geomap/components/DebugOverlay.tsx b/public/app/plugins/panel/geomap/components/DebugOverlay.tsx index de267de908f..d08d2042f05 100644 --- a/public/app/plugins/panel/geomap/components/DebugOverlay.tsx +++ b/public/app/plugins/panel/geomap/components/DebugOverlay.tsx @@ -6,7 +6,7 @@ import { PureComponent } from 'react'; import tinycolor from 'tinycolor2'; import { GrafanaTheme2 } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors/src'; +import { selectors } from '@grafana/e2e-selectors'; import { Trans } from '@grafana/i18n'; import { config } from 'app/core/config'; @@ -44,7 +44,7 @@ export class DebugOverlay extends PureComponent { const { zoom, center } = this.state; return ( -
+
diff --git a/scripts/cli/tsconfig.json b/scripts/cli/tsconfig.json index 468d9938751..057344a363c 100644 --- a/scripts/cli/tsconfig.json +++ b/scripts/cli/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { - "moduleResolution": "node", - "module": "commonjs" + "moduleResolution": "nodenext", + "module": "NodeNext" }, "extends": "../../tsconfig.json", "ts-node": { diff --git a/scripts/prepare-npm-package.js b/scripts/prepare-npm-package.js index 054b3d12609..7a11a2eca4a 100644 --- a/scripts/prepare-npm-package.js +++ b/scripts/prepare-npm-package.js @@ -1,108 +1,35 @@ +//@ts-check import PackageJson from '@npmcli/package-json'; -import { mkdir } from 'node:fs/promises'; const cwd = process.cwd(); try { const pkgJson = await PackageJson.load(cwd); - const cjsIndex = pkgJson.content.publishConfig?.main ?? pkgJson.content.main; - const esmIndex = pkgJson.content.publishConfig?.module ?? pkgJson.content.module; - const typesIndex = pkgJson.content.publishConfig?.types ?? pkgJson.content.types; + const pkgJsonExports = pkgJson.content.exports; - const exports = { - './package.json': './package.json', - '.': { - import: { - types: typesIndex, - default: esmIndex, - }, - require: { - types: typesIndex, - default: cjsIndex, - }, - }, - }; - // Fix so scenes can access `@grafana/schema` nested dist import paths e.g. - // import {} from '@grafana/schema/dist/esm/raw/composable/bargauge/panelcfg/x/BarGaugePanelCfg_types.gen' - if (pkgJson.content.name === '@grafana/schema') { - exports['./dist/*'] = { - types: './dist/*', - default: './dist/*', - }; - } - - // Fix for @grafana/i18n so eslint-plugin can be imported by consumers - if (pkgJson.content.name === '@grafana/i18n') { - exports['./eslint-plugin'] = { - types: './dist/eslint/index.d.ts', - import: './dist/eslint/index.cjs', - require: './dist/eslint/index.cjs', - }; - } - - pkgJson.update({ - main: cjsIndex, - types: typesIndex, - module: esmIndex, - exports, - }); - - await pkgJson.save(); - - // If an alias package name is provided we add an exports entry for the alias - // then generate an additional "nested" package.json for typescript resolution that - // doesn't use the exports property in package.json. - if (process.env.ALIAS_PACKAGE_NAME) { - const aliasNames = process.env.ALIAS_PACKAGE_NAME.split(','); - - const additionalExports = aliasNames.reduce((acc, alias) => { - acc[`./${alias}`] = { - import: { - types: typesIndex.replace('index', alias), - default: esmIndex.replace('index', alias), - }, - require: { - types: typesIndex.replace('index', alias), - default: cjsIndex.replace('index', alias), - }, - }; - return acc; - }, {}); + // skip packages without exports otherwise consumers cannot import anything from the package + if (pkgJsonExports && typeof pkgJsonExports === 'object') { + // Remove all exports that only contain a single key '@grafana-app/source' + // as these will not resolve when validating packages with attw because the + // source code is not available in the tarball. + for (const [key, val] of Object.entries(pkgJsonExports)) { + if ( + val !== null && + typeof val === 'object' && + Object.keys(val).length === 1 && + Object.keys(val)[0] === '@grafana-app/source' + ) { + delete pkgJsonExports[key]; + } + } pkgJson.update({ - exports: { - ...pkgJson.content.exports, - ...additionalExports, - }, - files: [...pkgJson.content.files, ...aliasNames], + exports: pkgJsonExports, }); - await pkgJson.save(); - for await (const aliasName of aliasNames) { - await createAliasPackageJsonFiles(pkgJson.content, aliasName); - } + await pkgJson.save(); } } catch (e) { console.error(e); process.exit(1); } - -async function createAliasPackageJsonFiles(packageJsonContent, aliasName) { - const pkgName = `${packageJsonContent.name}/${aliasName}`; - try { - console.log(`📦 Writing alias package.json for ${pkgName}.`); - const pkgJsonPath = `${cwd}/${aliasName}`; - await mkdir(pkgJsonPath, { recursive: true }); - const pkgJson = await PackageJson.create(pkgJsonPath, { - data: { - name: pkgName, - types: `../dist/types/${aliasName}.d.ts`, - main: `../dist/cjs/${aliasName}.cjs`, - module: `../dist/esm/${aliasName}.mjs`, - }, - }); - await pkgJson.save(); - } catch (error) { - throw new Error(`Error generating package.json for ${pkgName}`, error); - } -} diff --git a/scripts/tsconfig.base.json b/scripts/tsconfig.base.json index 55eacdf4b61..152674b91e7 100644 --- a/scripts/tsconfig.base.json +++ b/scripts/tsconfig.base.json @@ -4,6 +4,7 @@ "alwaysStrict": true, "strict": true, "allowSyntheticDefaultImports": true, + "customConditions": ["@grafana-app/source"], "downlevelIteration": true, "esModuleInterop": true, "experimentalDecorators": true, diff --git a/scripts/validate-npm-packages.sh b/scripts/validate-npm-packages.sh index 92e871b6899..99474041107 100755 --- a/scripts/validate-npm-packages.sh +++ b/scripts/validate-npm-packages.sh @@ -17,7 +17,7 @@ for file in "$ARTIFACTS_DIR"/*.tgz; do fi # shellcheck disable=SC2086 - if ! yarn attw "$file" --ignore-rules "false-cjs" $ATTW_FLAGS; then + if ! NODE_OPTIONS="-C @grafana-app/source" yarn attw "$file" --ignore-rules "false-cjs" $ATTW_FLAGS; then echo "attw check failed for $file" echo "" failed_checks+=("$file - yarn attw") diff --git a/scripts/webpack/webpack.common.js b/scripts/webpack/webpack.common.js index e694b812100..f81c279d6a3 100644 --- a/scripts/webpack/webpack.common.js +++ b/scripts/webpack/webpack.common.js @@ -22,6 +22,7 @@ module.exports = { publicPath: 'public/build/', }, resolve: { + conditionNames: ['@grafana-app/source', '...'], extensions: ['.ts', '.tsx', '.es6', '.js', '.json', '.svg'], alias: { // some of data source plugins use global Prism object to add the language definition diff --git a/tsconfig.json b/tsconfig.json index 963aa84555c..46f7b630cb2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,9 +18,10 @@ } }, "ts-node": { + "swc": true, "compilerOptions": { "module": "ESNext", - "moduleResolution": "Node" + "moduleResolution": "Bundler" } }, "include": [ diff --git a/yarn.lock b/yarn.lock index 1c4d07dbb6e..e20676b04f0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8290,6 +8290,30 @@ __metadata: languageName: node linkType: hard +"@storybook/core@patch:@storybook/core@npm%3A8.6.2#~/.yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch": + version: 8.6.2 + resolution: "@storybook/core@patch:@storybook/core@npm%3A8.6.2#~/.yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch::version=8.6.2&hash=f4cc1f" + dependencies: + "@storybook/theming": "npm:8.6.2" + better-opn: "npm:^3.0.2" + browser-assert: "npm:^1.2.1" + esbuild: "npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0" + esbuild-register: "npm:^3.5.0" + jsdoc-type-pratt-parser: "npm:^4.0.0" + process: "npm:^0.11.10" + recast: "npm:^0.23.5" + semver: "npm:^7.6.2" + util: "npm:^0.12.5" + ws: "npm:^8.2.3" + peerDependencies: + prettier: ^2 || ^3 + peerDependenciesMeta: + prettier: + optional: true + checksum: 10/cd95a51437135dd3c4333b14acefd528d8064b2cea7789f859ba80783c115c92ed4be51d4a7bd6236888fdd5f46f488a379e0c71bc1a712ffe6dc1353fb4e648 + languageName: node + linkType: hard + "@storybook/csf-plugin@npm:8.6.2": version: 8.6.2 resolution: "@storybook/csf-plugin@npm:8.6.2" From e29bb47e95006886b512792901a21c2e60536d2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Jim=C3=A9nez=20S=C3=A1nchez?= Date: Thu, 18 Dec 2025 13:16:57 +0100 Subject: [PATCH 013/229] Provisioning: Add Git Sync limitations warning and migrate resources checkbox (#115532) * Provisioning: Add Git Sync limitations warning and migrate resources checkbox - Update SynchronizeStep alert to use warning severity with comprehensive Git Sync limitations - Add conditional warnings for instance sync (permissions loss, alerts/library panels loss) - Add conditional warnings for folder sync (folder structure changes, manual cleanup needed) - Add "Migrate existing resources" checkbox for folder sync mode - Update useCreateSyncJob hook to handle migrateResources option for folder sync - Extract i18n translations for new strings * Simplify createSyncJob: calculate requiresMigration in caller - Remove syncTarget and migrateResources parameters from useCreateSyncJob hook - Calculate requiresMigration in SynchronizeStep based on sync target and checkbox value - Pass requiresMigration as parameter to createSyncJob function * Revert: Pass requiresMigration as hook parameter - Calculate requiresMigration in SynchronizeStep using useMemo - Pass requiresMigration to useCreateSyncJob hook - Remove parameter from createSyncJob function call * Revert "Revert: Pass requiresMigration as hook parameter" This reverts commit 97e3b7107d282d10a1be487d9186daaaf62f41aa. * Fix TypeScript errors in ProvisioningWizard - Remove requiresMigration from useCreateSyncJob call - Pass requiresMigration parameter to createSyncJob call - Remove unused Target import from SynchronizeStep * Show migrate resources checkbox for instance sync (checked and disabled) - Display checkbox for both instance and folder sync - For instance sync: checkbox is checked and disabled with explanation - For instance sync: automatically set migrateResources to true via useEffect - Update description to explain instance sync requires all resources to be managed * Extract i18n translations for instance-migrate-resources-description * Rename 'Synchronization options' to 'Options' * Update i18n translations: rename synchronization-options to options * Remove unnecessary conditional check for sync target * Add bodySmall variant to announcement banner TextLink * Move requiresMigration calculation logic into useResourceStats hook - Add migrateResources parameter to useResourceStats hook - Calculate final requiresMigration in hook based on sync target and checkbox value - Use watch instead of getValues to reactively get migrateResources value - Simplify startSynchronization to use requiresMigration from hook --- .../Wizard/ProvisioningWizard.tsx | 11 +- .../provisioning/Wizard/SynchronizeStep.tsx | 131 ++++++++++++++---- .../Wizard/hooks/useCreateSyncJob.ts | 5 +- .../Wizard/hooks/useResourceStats.ts | 19 ++- .../app/features/provisioning/Wizard/types.ts | 1 + public/locales/en-US/grafana.json | 13 +- 6 files changed, 135 insertions(+), 45 deletions(-) diff --git a/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx b/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx index 32829dba4c0..0950500a378 100644 --- a/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx +++ b/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx @@ -117,14 +117,9 @@ export const ProvisioningWizard = memo(function ProvisioningWizard({ const [repoName = '', repoType, syncTarget] = watch(['repositoryName', 'repository.type', 'repository.sync.target']); const [submitData] = useCreateOrUpdateRepository(repoName); const [deleteRepository] = useDeleteRepositoryMutation(); - const { - shouldSkipSync, - requiresMigration, - isLoading: isResourceStatsLoading, - } = useResourceStats(repoName, syncTarget); + const { shouldSkipSync, isLoading: isResourceStatsLoading } = useResourceStats(repoName, syncTarget); const { createSyncJob, isLoading: isCreatingSkipJob } = useCreateSyncJob({ repoName: repoName, - requiresMigration, setStepStatusInfo, }); @@ -274,8 +269,8 @@ export const ProvisioningWizard = memo(function ProvisioningWizard({ if (activeStep === 'bootstrap' && canSkipSync) { nextStepIndex = currentStepIndex + 2; // Skip to finish step - // Create a pull job to initialize the repository - const job = await createSyncJob(); + // No migration needed when skipping sync + const job = await createSyncJob(false); if (!job) { return; // Don't proceed if job creation fails } diff --git a/public/app/features/provisioning/Wizard/SynchronizeStep.tsx b/public/app/features/provisioning/Wizard/SynchronizeStep.tsx index 5842b313060..299fe775a0e 100644 --- a/public/app/features/provisioning/Wizard/SynchronizeStep.tsx +++ b/public/app/features/provisioning/Wizard/SynchronizeStep.tsx @@ -3,7 +3,7 @@ import { memo, useEffect, useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { Trans, t } from '@grafana/i18n'; -import { Alert, Button, Field, Spinner, Stack, Text, TextLink } from '@grafana/ui'; +import { Alert, Button, Checkbox, Field, Spinner, Stack, Text, TextLink } from '@grafana/ui'; import { Job, useGetRepositoryStatusQuery } from 'app/api/clients/provisioning/v0alpha1'; import { JobStatus } from '../Job/JobStatus'; @@ -20,13 +20,17 @@ export interface SynchronizeStepProps { } export const SynchronizeStep = memo(function SynchronizeStep({ onCancel, isCancelling }: SynchronizeStepProps) { - const { watch } = useFormContext(); + const { watch, register } = useFormContext(); const { setStepStatusInfo } = useStepStatus(); - const [repoName = '', syncTarget] = watch(['repositoryName', 'repository.sync.target']); - const { requiresMigration } = useResourceStats(repoName, syncTarget); + const [repoName = '', syncTarget, migrateResources] = watch([ + 'repositoryName', + 'repository.sync.target', + 'migrate.migrateResources', + ]); + const { requiresMigration } = useResourceStats(repoName, syncTarget, migrateResources); + const { createSyncJob } = useCreateSyncJob({ repoName, - requiresMigration, setStepStatusInfo, }); const [job, setJob] = useState(); @@ -63,7 +67,7 @@ export const SynchronizeStep = memo(function SynchronizeStep({ onCancel, isCance const isButtonDisabled = hasError || (checked !== undefined && isRepositoryHealthy === false) || healthStatusNotReady; const startSynchronization = async () => { - const response = await createSyncJob(); + const response = await createSyncJob(requiresMigration); if (response) { setJob(response); } @@ -108,41 +112,108 @@ export const SynchronizeStep = memo(function SynchronizeStep({ onCancel, isCance )} {isRepositoryHealthy && ( -
    -
  • - - Resources can still be created, edited, or deleted during this process, but changes may not be exported. + + + + Please be aware of the following limitations. For more details, see the{' '} + + Git Sync documentation + + . -
  • -
  • - - Once provisioning is complete, resources will be marked as managed through external storage. - -
  • -
  • - - The duration of this process depends on the number of resources involved. - -
  • -
  • + +
      +
    • + + Resources can still be created, edited, or deleted during this process, but changes may not be + exported. + +
    • +
    • + + Alerts and library panels are not supported in provisioned folders. + +
    • +
    • + + Fine-grained permissions are not supported. Default permissions apply: Admin, Editor, and Viewer roles + are preserved with their standard access levels. + +
    • +
    • + + The duration of this process depends on the number of resources involved. + +
    • + {syncTarget === 'instance' && ( +
    • + + Existing alerts and library panels will be lost and will not be usable after migration. + +
    • + )} + {syncTarget === 'folder' && ( + <> +
    • + + When migrating existing dashboards, the folder structure will be replicated in the repository. + Original folders will be emptied of dashboards but may still contain alerts or library panels. + +
    • +
    • + + You may need to manually remove or manage original folders after migration. + +
    • + + )} +
    + Enterprise instance administrators can display an announcement banner to notify users that migration is in progress. See{' '} - + this guide {' '} for step-by-step instructions. -
  • -
+ +
)} + + Options + + + + Instance sync requires all resources to be managed. Existing resources will be migrated automatically. + + ) : ( + + Import existing dashboards from all folders into the new provisioned folder + + ) + } + /> + {healthStatusNotReady ? ( <> diff --git a/public/app/features/provisioning/Wizard/hooks/useCreateSyncJob.ts b/public/app/features/provisioning/Wizard/hooks/useCreateSyncJob.ts index 7e3c0c30874..1aaa7a250f0 100644 --- a/public/app/features/provisioning/Wizard/hooks/useCreateSyncJob.ts +++ b/public/app/features/provisioning/Wizard/hooks/useCreateSyncJob.ts @@ -5,14 +5,13 @@ import { StepStatusInfo } from '../types'; export interface UseCreateSyncJobParams { repoName: string; - requiresMigration: boolean; setStepStatusInfo?: (info: StepStatusInfo) => void; } -export function useCreateSyncJob({ repoName, requiresMigration, setStepStatusInfo }: UseCreateSyncJobParams) { +export function useCreateSyncJob({ repoName, setStepStatusInfo }: UseCreateSyncJobParams) { const [createJob, { isLoading }] = useCreateRepositoryJobsMutation(); - const createSyncJob = async () => { + const createSyncJob = async (requiresMigration: boolean) => { if (!repoName) { setStepStatusInfo?.({ status: 'error', diff --git a/public/app/features/provisioning/Wizard/hooks/useResourceStats.ts b/public/app/features/provisioning/Wizard/hooks/useResourceStats.ts index 7e2ffef178b..dd4b222cb19 100644 --- a/public/app/features/provisioning/Wizard/hooks/useResourceStats.ts +++ b/public/app/features/provisioning/Wizard/hooks/useResourceStats.ts @@ -100,7 +100,7 @@ function getResourceStats(files?: GetRepositoryFilesApiResponse, stats?: GetReso /** * Hook that provides resource statistics and sync logic */ -export function useResourceStats(repoName?: string, syncTarget?: RepositoryView['target']) { +export function useResourceStats(repoName?: string, syncTarget?: RepositoryView['target'], migrateResources?: boolean) { const resourceStatsQuery = useGetResourceStatsQuery(repoName ? undefined : skipToken); const filesQuery = useGetRepositoryFilesQuery(repoName ? { name: repoName } : skipToken); @@ -121,7 +121,22 @@ export function useResourceStats(repoName?: string, syncTarget?: RepositoryView[ }; }, [resourceStatsQuery.data]); - const requiresMigration = resourceCount > 0 && syncTarget === 'instance'; + // Calculate base requiresMigration: true if there are resources to migrate + const baseRequiresMigration = resourceCount > 0; + + // Calculate final requiresMigration based on sync target and user selection + // For instance sync: always use baseRequiresMigration (checkbox is disabled and always true) + // For folder sync: only migrate if user explicitly opts in via checkbox + const requiresMigration = useMemo(() => { + if (syncTarget === 'instance') { + return baseRequiresMigration; + } + if (syncTarget === 'folder') { + return migrateResources ?? false; + } + return baseRequiresMigration; + }, [syncTarget, baseRequiresMigration, migrateResources]); + const shouldSkipSync = (resourceCount === 0 || syncTarget === 'folder') && fileCount === 0; // Format display strings diff --git a/public/app/features/provisioning/Wizard/types.ts b/public/app/features/provisioning/Wizard/types.ts index 0dc2f001a43..34ee246da04 100644 --- a/public/app/features/provisioning/Wizard/types.ts +++ b/public/app/features/provisioning/Wizard/types.ts @@ -9,6 +9,7 @@ export type RepoType = RepositorySpec['type']; export interface MigrateFormData { history: boolean; identifier: boolean; + migrateResources?: boolean; } export interface WizardFormData { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 79b1f20b8e0..8c7bc95d901 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -12182,6 +12182,9 @@ "tooltip-unhealthy-repository": "Unable to pull an unhealthy repository" }, "synchronize-step": { + "instance-migrate-resources-description": "Instance sync requires all resources to be managed. Existing resources will be migrated automatically.", + "migrate-resources-description": "Import existing dashboards from all folders into the new provisioned folder", + "options": "Options", "repository-error": "Repository error", "repository-error-message": "Unable to check repository status. Please verify the repository configuration and try again.", "repository-unhealthy": "The repository cannot be synchronized. Cancel provisioning and try again once the issue has been resolved. See details below." @@ -12201,11 +12204,16 @@ }, "warning-title-default": "Warning", "wizard": { + "alert-intro": "Please be aware of the following limitations. For more details, see the <2>Git Sync documentation.", "alert-point-1": "Resources can still be created, edited, or deleted during this process, but changes may not be exported.", - "alert-point-2": "Once provisioning is complete, resources will be marked as managed through external storage.", "alert-point-3": "The duration of this process depends on the number of resources involved.", "alert-point-4": "Enterprise instance administrators can display an announcement banner to notify users that migration is in progress. See <2>this guide for step-by-step instructions.", - "alert-title": "Important: No data or configuration will be lost. Dashboards remain accessible during migration, but changes made during this process may not be exported.", + "alert-point-folder-cleanup": "You may need to manually remove or manage original folders after migration.", + "alert-point-folder-structure": "When migrating existing dashboards, the folder structure will be replicated in the repository. Original folders will be emptied of dashboards but may still contain alerts or library panels.", + "alert-point-instance-alerts": "Existing alerts and library panels will be lost and will not be usable after migration.", + "alert-point-permissions": "Fine-grained permissions are not supported. Default permissions apply: Admin, Editor, and Viewer roles are preserved with their standard access levels.", + "alert-point-unsupported": "Alerts and library panels are not supported in provisioned folders.", + "alert-title": "Important: Review Git Sync limitations before proceeding", "button-cancel": "Cancel", "button-cancelling": "Cancelling...", "button-next": "Finish", @@ -12223,6 +12231,7 @@ "step-finish": "Choose additional settings", "step-synchronize": "Synchronize with external storage", "sync-description": "Sync resources with external storage. After this one-time step, all future updates will be automatically saved to the repository and provisioned back into the instance.", + "sync-option-migrate-resources": "Migrate existing resources", "title-bootstrap": "Choose what to synchronize", "title-connect": "Connect to external storage", "title-finish": "Choose additional settings", From 241fd69e028f922bb38de6b46dd1388304b9e62e Mon Sep 17 00:00:00 2001 From: Andre Pereira Date: Thu, 18 Dec 2025 12:38:50 +0000 Subject: [PATCH 014/229] Trace View: Correctly handle span and service name in span filters (#115215) * Correctly handle span name and service name in trace view span filters * Consistency and fix test * i18n extract --- .../SpanFilters/SpanFilters.test.tsx | 262 -------------- .../SpanFilters/SpanFilters.tsx | 319 ------------------ .../SpanFilters/SpanFiltersTags.tsx | 202 ----------- .../useTraceAdHocFiltersController.ts | 15 - .../TraceView/components/constants/span.ts | 2 + .../components/utils/filter-spans.tsx | 20 +- .../explore/TraceView/useSearch.test.ts | 14 +- .../features/explore/TraceView/useSearch.ts | 5 +- .../features/explore/TraceView/utils/tags.ts | 15 + public/locales/en-US/grafana.json | 33 -- 10 files changed, 42 insertions(+), 845 deletions(-) delete mode 100644 public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.test.tsx delete mode 100644 public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.tsx delete mode 100644 public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFiltersTags.tsx diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.test.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.test.tsx deleted file mode 100644 index eba49425afb..00000000000 --- a/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.test.tsx +++ /dev/null @@ -1,262 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { useState } from 'react'; - -import { DEFAULT_SPAN_FILTERS } from 'app/features/explore/state/constants'; - -import { Trace } from '../../types/trace'; - -import { SpanFilters } from './SpanFilters'; - -const trace: Trace = { - traceID: '1ed38015486087ca', - spans: [ - { - traceID: '1ed38015486087ca', - spanID: '1ed38015486087ca', - operationName: 'Span0', - tags: [{ key: 'TagKey0', type: 'string', value: 'TagValue0' }], - kind: 'server', - statusCode: 2, - statusMessage: 'message', - instrumentationLibraryName: 'name', - instrumentationLibraryVersion: 'version', - traceState: 'state', - process: { - serviceName: 'Service0', - tags: [{ key: 'ProcessKey0', type: 'string', value: 'ProcessValue0' }], - }, - logs: [{ fields: [{ key: 'LogKey0', type: 'string', value: 'LogValue0' }] }], - }, - { - traceID: '1ed38015486087ca', - spanID: '2ed38015486087ca', - operationName: 'Span1', - tags: [{ key: 'TagKey1', type: 'string', value: 'TagValue1' }], - process: { - serviceName: 'Service1', - tags: [{ key: 'ProcessKey1', type: 'string', value: 'ProcessValue1' }], - }, - logs: [{ fields: [{ key: 'LogKey1', type: 'string', value: 'LogValue1' }] }], - }, - ], - processes: { - '1ed38015486087ca': { - serviceName: 'Service0', - tags: [], - }, - }, -} as unknown as Trace; - -describe('SpanFilters', () => { - let user: ReturnType; - const SpanFiltersWithProps = ({ showFilters = true, matches }: { showFilters?: boolean; matches?: Set }) => { - const [search, setSearch] = useState(DEFAULT_SPAN_FILTERS); - const props = { - trace: trace, - showSpanFilters: showFilters, - setShowSpanFilters: jest.fn(), - search, - setSearch, - spanFilterMatches: matches, - setFocusedSpanIdForSearch: jest.fn(), - datasourceType: 'tempo', - }; - - return ; - }; - - beforeEach(() => { - jest.useFakeTimers(); - // Need to use delay: null here to work with fakeTimers - // see https://github.com/testing-library/user-event/issues/833 - user = userEvent.setup({ delay: null }); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('should render', () => { - expect(() => render()).not.toThrow(); - }); - - it('should render filters', async () => { - render(); - - const serviceOperator = screen.getByLabelText('Select service name operator'); - const serviceValue = screen.getByLabelText('Select service name'); - const spanOperator = screen.getByLabelText('Select span name operator'); - const spanValue = screen.getByLabelText('Select span name'); - const fromOperator = screen.getByLabelText('Select min span operator'); - const fromValue = screen.getByLabelText('Select min span duration'); - const toOperator = screen.getByLabelText('Select max span operator'); - const toValue = screen.getByLabelText('Select max span duration'); - const tagKey = screen.getByLabelText('Select tag key'); - const tagOperator = screen.getByLabelText('Select tag operator'); - const tagSelectValue = screen.getByLabelText('Select tag value'); - - expect(serviceOperator).toBeInTheDocument(); - expect(getElemText(serviceOperator)).toBe('='); - expect(serviceValue).toBeInTheDocument(); - expect(spanOperator).toBeInTheDocument(); - expect(getElemText(spanOperator)).toBe('='); - expect(spanValue).toBeInTheDocument(); - expect(fromOperator).toBeInTheDocument(); - expect(getElemText(fromOperator)).toBe('>'); - expect(fromValue).toBeInTheDocument(); - expect(toOperator).toBeInTheDocument(); - expect(getElemText(toOperator)).toBe('<'); - expect(toValue).toBeInTheDocument(); - expect(tagKey).toBeInTheDocument(); - expect(tagOperator).toBeInTheDocument(); - expect(getElemText(tagOperator)).toBe('='); - expect(tagSelectValue).toBeInTheDocument(); - - await user.click(serviceValue); - jest.advanceTimersByTime(1000); - await waitFor(() => { - expect(screen.getByText('Service0')).toBeInTheDocument(); - expect(screen.getByText('Service1')).toBeInTheDocument(); - }); - await user.click(spanValue); - jest.advanceTimersByTime(1000); - await waitFor(() => { - expect(screen.getByText('Span0')).toBeInTheDocument(); - expect(screen.getByText('Span1')).toBeInTheDocument(); - }); - await user.click(tagOperator); - jest.advanceTimersByTime(1000); - await waitFor(() => { - expect(screen.getByText('!~')).toBeInTheDocument(); - expect(screen.getByText('=~')).toBeInTheDocument(); - expect(screen.getByText('!~')).toBeInTheDocument(); - }); - await user.click(tagKey); - jest.advanceTimersByTime(1000); - await waitFor(() => { - expect(screen.getByText('TagKey0')).toBeInTheDocument(); - expect(screen.getByText('TagKey1')).toBeInTheDocument(); - expect(screen.getByText('kind')).toBeInTheDocument(); - expect(screen.getByText('ProcessKey0')).toBeInTheDocument(); - expect(screen.getByText('ProcessKey1')).toBeInTheDocument(); - expect(screen.getByText('LogKey0')).toBeInTheDocument(); - expect(screen.getByText('LogKey1')).toBeInTheDocument(); - expect(screen.getByPlaceholderText('Find...')).toBeInTheDocument(); - }); - }); - - it('should update filters', async () => { - render(); - const serviceValue = screen.getByLabelText('Select service name'); - const spanValue = screen.getByLabelText('Select span name'); - const tagKey = screen.getByLabelText('Select tag key'); - const tagOperator = screen.getByLabelText('Select tag operator'); - const tagValue = screen.getByLabelText('Select tag value'); - - expect(getElemText(serviceValue)).toBe('All service names'); - await selectAndCheckValue(user, serviceValue, 'Service0'); - expect(getElemText(spanValue)).toBe('All span names'); - await selectAndCheckValue(user, spanValue, 'Span0'); - - await user.click(tagValue); - jest.advanceTimersByTime(1000); - await waitFor(() => expect(screen.getByText('No options found')).toBeInTheDocument()); - - expect(getElemText(tagKey)).toBe('Select tag'); - await selectAndCheckValue(user, tagKey, 'TagKey0'); - expect(getElemText(tagValue)).toBe('Select value'); - await selectAndCheckValue(user, tagValue, 'TagValue0'); - expect(screen.queryByLabelText('Input tag value')).toBeNull(); - await selectAndCheckValue(user, tagOperator, '=~'); - expect(screen.getByLabelText('Input tag value')).toBeInTheDocument(); - }); - - it('should order tag filters', async () => { - render(); - const tagKey = screen.getByLabelText('Select tag key'); - - await user.click(tagKey); - jest.advanceTimersByTime(1000); - await waitFor(() => { - const container = screen.getByText('TagKey0').parentElement?.parentElement?.parentElement; - expect(container?.childNodes[1].textContent).toBe('ProcessKey0'); - expect(container?.childNodes[2].textContent).toBe('ProcessKey1'); - expect(container?.childNodes[3].textContent).toBe('TagKey0'); - expect(container?.childNodes[4].textContent).toBe('TagKey1'); - expect(container?.childNodes[5].textContent).toBe('id'); - expect(container?.childNodes[6].textContent).toBe('kind'); - expect(container?.childNodes[7].textContent).toBe('library.name'); - expect(container?.childNodes[8].textContent).toBe('library.version'); - expect(container?.childNodes[9].textContent).toBe('status'); - expect(container?.childNodes[10].textContent).toBe('status.message'); - expect(container?.childNodes[11].textContent).toBe('trace.state'); - expect(container?.childNodes[12].textContent).toBe('LogKey0'); - expect(container?.childNodes[13].textContent).toBe('LogKey1'); - }); - }); - - it('should only show add/remove tag when necessary', async () => { - render(); - expect(screen.queryAllByLabelText('Add tag').length).toBe(0); // not filled in the default tag, so no need to add another one - expect(screen.queryAllByLabelText('Remove tag').length).toBe(0); // mot filled in the default tag, so no values to remove - expect(screen.getAllByLabelText('Select tag key').length).toBe(1); - - await selectAndCheckValue(user, screen.getByLabelText('Select tag key'), 'TagKey0'); - expect(screen.getAllByLabelText('Add tag').length).toBe(1); - expect(screen.getAllByLabelText('Remove tag').length).toBe(1); - - await user.click(screen.getByLabelText('Add tag')); - jest.advanceTimersByTime(1000); - expect(screen.queryAllByLabelText('Add tag').length).toBe(0); // not filled in the new tag, so no need to add another one - expect(screen.getAllByLabelText('Remove tag').length).toBe(2); // one for each tag - expect(screen.getAllByLabelText('Select tag key').length).toBe(2); - - await user.click(screen.getAllByLabelText('Remove tag')[1]); - jest.advanceTimersByTime(1000); - expect(screen.queryAllByLabelText('Add tag').length).toBe(1); // filled in the default tag, so can add another one - expect(screen.queryAllByLabelText('Remove tag').length).toBe(1); // filled in the default tag, so can remove values - expect(screen.getAllByLabelText('Select tag key').length).toBe(1); - - await user.click(screen.getAllByLabelText('Remove tag')[0]); - jest.advanceTimersByTime(1000); - expect(screen.queryAllByLabelText('Add tag').length).toBe(0); // not filled in the default tag, so no need to add another one - expect(screen.queryAllByLabelText('Remove tag').length).toBe(0); // mot filled in the default tag, so no values to remove - expect(screen.getAllByLabelText('Select tag key').length).toBe(1); - }); - - it('should allow adding/removing tags', async () => { - render(); - expect(screen.getAllByLabelText('Select tag key').length).toBe(1); - const tagKey = screen.getByLabelText('Select tag key'); - await selectAndCheckValue(user, tagKey, 'TagKey0'); - - await user.click(screen.getByLabelText('Add tag')); - jest.advanceTimersByTime(1000); - expect(screen.getAllByLabelText('Select tag key').length).toBe(2); - - await user.click(screen.getAllByLabelText('Remove tag')[0]); - jest.advanceTimersByTime(1000); - expect(screen.getAllByLabelText('Select tag key').length).toBe(1); - }); - - it('renders buttons when span filters is collapsed', async () => { - render(); - expect(screen.queryByRole('button', { name: 'Next result button' })).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Prev result button' })).toBeInTheDocument(); - }); -}); - -const selectAndCheckValue = async (user: ReturnType, elem: HTMLElement, text: string) => { - await user.click(elem); - jest.advanceTimersByTime(1000); - await waitFor(() => expect(screen.getByText(text)).toBeInTheDocument()); - - await user.click(screen.getByText(text)); - jest.advanceTimersByTime(1000); - expect(screen.getByText(text)).toBeInTheDocument(); -}; - -const getElemText = (elem: HTMLElement) => { - return elem.parentElement?.previousSibling?.textContent; -}; diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.tsx deleted file mode 100644 index eda085bc412..00000000000 --- a/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.tsx +++ /dev/null @@ -1,319 +0,0 @@ -// Copyright (c) 2017 Uber Technologies, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { css } from '@emotion/css'; -import React, { useState, useEffect, memo, useCallback, useRef } from 'react'; - -import { GrafanaTheme2, TraceSearchProps, SelectableValue, toOption } from '@grafana/data'; -import { Trans, t } from '@grafana/i18n'; -import { IntervalInput } from '@grafana/o11y-ds-frontend'; -import { Collapse, Icon, InlineField, InlineFieldRow, Select, Stack, Tooltip, useStyles2 } from '@grafana/ui'; - -import { DEFAULT_SPAN_FILTERS } from '../../../../state/constants'; -import { getTraceServiceNames, getTraceSpanNames } from '../../../utils/tags'; -import SearchBarInput from '../../common/SearchBarInput'; -import { Trace } from '../../types/trace'; -import NextPrevResult from '../SearchBar/NextPrevResult'; -import TracePageSearchBar from '../SearchBar/TracePageSearchBar'; - -import { SpanFiltersTags } from './SpanFiltersTags'; - -export type SpanFilterProps = { - trace: Trace; - search: TraceSearchProps; - setSearch: (newSearch: TraceSearchProps) => void; - showSpanFilters: boolean; - setShowSpanFilters: (isOpen: boolean) => void; - setFocusedSpanIdForSearch: React.Dispatch>; - spanFilterMatches: Set | undefined; - datasourceType: string; -}; - -export const SpanFilters = memo((props: SpanFilterProps) => { - const { - trace, - search, - setSearch, - showSpanFilters, - setShowSpanFilters, - setFocusedSpanIdForSearch, - spanFilterMatches, - datasourceType, - } = props; - const styles = { ...useStyles2(getStyles) }; - const [serviceNames, setServiceNames] = useState>>(); - const [spanNames, setSpanNames] = useState>>(); - const [focusedSpanIndexForSearch, setFocusedSpanIndexForSearch] = useState(-1); - const [tagKeys, setTagKeys] = useState>>(); - const [tagValues, setTagValues] = useState<{ [key: string]: Array> }>({}); - const prevTraceIdRef = useRef(); - - const durationRegex = /^\d+(?:\.\d)?\d*(?:ns|us|µs|ms|s|m|h)$/; - - const clear = useCallback(() => { - setServiceNames(undefined); - setSpanNames(undefined); - setTagKeys(undefined); - setTagValues({}); - setSearch(DEFAULT_SPAN_FILTERS); - }, [setSearch]); - - useEffect(() => { - // Only clear filters when trace ID actually changes (not on initial mount) - const currentTraceId = trace?.traceID; - - const traceHasChanged = prevTraceIdRef.current && prevTraceIdRef.current !== currentTraceId; - - if (traceHasChanged) { - clear(); - } - - prevTraceIdRef.current = currentTraceId; - }, [clear, trace]); - - const setShowSpanFilterMatchesOnly = useCallback( - (showMatchesOnly: boolean) => { - setSearch({ ...search, matchesOnly: showMatchesOnly }); - }, - [search, setSearch] - ); - - if (!trace) { - return null; - } - - const setSpanFiltersSearch = (spanSearch: TraceSearchProps) => { - setFocusedSpanIndexForSearch(-1); - setFocusedSpanIdForSearch(''); - setSearch(spanSearch); - }; - - const getServiceNames = () => { - if (!serviceNames) { - setServiceNames(getTraceServiceNames(trace).map(toOption)); - } - }; - - const getSpanNames = () => { - if (!spanNames) { - setSpanNames(getTraceSpanNames(trace).map(toOption)); - } - }; - - const collapseLabel = ( - <> - - - Span Filters - - - - - {!showSpanFilters && ( -
- -
- )} - - ); - - return ( -
- - - - - setSpanFiltersSearch({ ...search, serviceName: v?.value || '' })} - onOpenMenu={getServiceNames} - options={serviceNames || (search.serviceName ? [search.serviceName].map(toOption) : [])} - placeholder={t('explore.span-filters.placeholder-all-service-names', 'All service names')} - value={search.serviceName || null} - defaultValue={search.serviceName || null} - /> - - - { - setSpanFiltersSearch({ ...search, query: v, matchesOnly: v !== '' }); - }} - value={search.query || ''} - /> - - - - - setSpanFiltersSearch({ ...search, spanName: v?.value || '' })} - onOpenMenu={getSpanNames} - options={spanNames || (search.spanName ? [search.spanName].map(toOption) : [])} - placeholder={t('explore.span-filters.placeholder-all-span-names', 'All span names')} - value={search.spanName || null} - /> - - - - - - - setSpanFiltersSearch({ ...search, toOperator: v.value! })} - options={[toOption('<'), toOption('<=')]} - value={search.toOperator} - /> - setSpanFiltersSearch({ ...search, to: val })} - isInvalidError="Invalid duration" - // eslint-disable-next-line @grafana/i18n/no-untranslated-strings - placeholder="e.g. 100ms, 1.2s" - width={18} - value={search.to || ''} - validationRegex={durationRegex} - /> - - - - - - - - - - - -
- ); -}); - -SpanFilters.displayName = 'SpanFilters'; - -const getStyles = (theme: GrafanaTheme2) => ({ - container: css({ - label: 'SpanFilters', - margin: `0.5em 0 -${theme.spacing(1)} 0`, - zIndex: 5, - - '& > div': { - borderLeft: 'none', - borderRight: 'none', - }, - }), - collapseLabel: css({ - svg: { - color: '#aaa', - margin: '-2px 0 0 10px', - }, - }), - flexContainer: css({ - display: 'flex', - justifyContent: 'space-between', - }), - intervalInput: css({ - margin: '0 -4px 0 0', - }), - tagsRow: css({ - margin: '-4px 0 0 0', - }), - nextPrevResult: css({ - flex: 1, - alignItems: 'center', - display: 'flex', - justifyContent: 'flex-end', - marginRight: theme.spacing(1), - }), -}); diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFiltersTags.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFiltersTags.tsx deleted file mode 100644 index 3cf2d5f8488..00000000000 --- a/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFiltersTags.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import { css } from '@emotion/css'; -import React from 'react'; -import { useMount } from 'react-use'; - -import { GrafanaTheme2, SelectableValue, toOption, TraceSearchProps, TraceSearchTag } from '@grafana/data'; -import { t } from '@grafana/i18n'; -import { AccessoryButton } from '@grafana/plugin-ui'; -import { Input, Select, Stack, useStyles2 } from '@grafana/ui'; - -import { randomId } from '../../../../state/constants'; -import { getTraceTagKeys, getTraceTagValues } from '../../../utils/tags'; -import { Trace } from '../../types/trace'; - -interface Props { - search: TraceSearchProps; - setSearch: (search: TraceSearchProps) => void; - trace: Trace; - tagKeys?: Array>; - setTagKeys: React.Dispatch> | undefined>>; - tagValues: Record>>; - setTagValues: React.Dispatch> }>>; -} - -export const SpanFiltersTags = ({ search, trace, setSearch, tagKeys, setTagKeys, tagValues, setTagValues }: Props) => { - const styles = { ...useStyles2(getStyles) }; - - const getTagKeys = () => { - if (!tagKeys) { - setTagKeys(getTraceTagKeys(trace).map(toOption)); - } - }; - - const getTagValues = (key: string) => { - return getTraceTagValues(trace, key).map(toOption); - }; - - useMount(() => { - if (search.tags) { - search.tags.forEach((tag) => { - if (tag.key) { - setTagValues({ - ...tagValues, - [tag.id]: getTagValues(tag.key), - }); - } - }); - } - }); - - const onTagChange = (tag: TraceSearchTag, v: SelectableValue) => { - setSearch({ - ...search, - tags: search.tags?.map((x) => { - return x.id === tag.id ? { ...x, key: v?.value || '', value: undefined } : x; - }), - }); - - const loadTagValues = async () => { - if (v?.value) { - setTagValues({ - ...tagValues, - [tag.id]: getTagValues(v.value), - }); - } else { - // removed value - const updatedValues = { ...tagValues }; - if (updatedValues[tag.id]) { - delete updatedValues[tag.id]; - } - setTagValues(updatedValues); - } - }; - loadTagValues(); - }; - - const addTag = () => { - const tag = { - id: randomId(), - operator: '=', - }; - setSearch({ ...search, tags: [...search.tags, tag] }); - }; - - const removeTag = (id: string) => { - let tags = search.tags.filter((tag) => { - return tag.id !== id; - }); - if (tags.length === 0) { - tags = [ - { - id: randomId(), - operator: '=', - }, - ]; - } - setSearch({ ...search, tags: tags }); - }; - - return ( -
- {search.tags?.map((tag, i) => ( -
- -
- { - setSearch({ - ...search, - tags: search.tags?.map((x) => { - return x.id === tag.id ? { ...x, operator: v.value! } : x; - }), - }); - }} - options={[toOption('='), toOption('!='), toOption('=~'), toOption('!~')]} - value={tag.operator} - /> -
- - - {(tag.operator === '=' || tag.operator === '!=') && ( - { - setSearch({ - ...search, - tags: search.tags?.map((x) => { - return x.id === tag.id ? { ...x, value: v?.currentTarget?.value || '' } : x; - }), - }); - }} - placeholder={t('explore.span-filters-tags.placeholder-tag-value', 'Tag value')} - width={18} - value={tag.value || ''} - /> - )} - - {(tag.key || tag.value || search.tags.length > 1) && ( - removeTag(tag.id)} - tooltip={t('explore.span-filters-tags.tooltip-remove-tag', 'Remove tag')} - /> - )} - {(tag.key || tag.value) && i === search.tags.length - 1 && ( - - - - )} -
-
- ))} -
- ); -}; - -const getStyles = (theme: GrafanaTheme2) => ({ - addTag: css({ - marginLeft: theme.spacing(1), - }), - tagValues: css({ - maxWidth: '200px', - }), -}); diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/useTraceAdHocFiltersController.ts b/public/app/features/explore/TraceView/components/TracePageHeader/useTraceAdHocFiltersController.ts index 8615c446294..c5286297087 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/useTraceAdHocFiltersController.ts +++ b/public/app/features/explore/TraceView/components/TracePageHeader/useTraceAdHocFiltersController.ts @@ -1,18 +1,3 @@ -// Copyright (c) 2025 Grafana Labs -// -// 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 . - import { useMemo, useState } from 'react'; import { TraceSearchProps } from '@grafana/data'; diff --git a/public/app/features/explore/TraceView/components/constants/span.ts b/public/app/features/explore/TraceView/components/constants/span.ts index 8d2fb1e5052..2e4d04be71b 100644 --- a/public/app/features/explore/TraceView/components/constants/span.ts +++ b/public/app/features/explore/TraceView/components/constants/span.ts @@ -5,3 +5,5 @@ export const LIBRARY_NAME = 'library.name'; export const LIBRARY_VERSION = 'library.version'; export const TRACE_STATE = 'trace.state'; export const ID = 'id'; +export const SPAN_NAME = 'span.name'; +export const SERVICE_NAME = 'service.name'; diff --git a/public/app/features/explore/TraceView/components/utils/filter-spans.tsx b/public/app/features/explore/TraceView/components/utils/filter-spans.tsx index d03016dab78..69c42ee2e8f 100644 --- a/public/app/features/explore/TraceView/components/utils/filter-spans.tsx +++ b/public/app/features/explore/TraceView/components/utils/filter-spans.tsx @@ -16,7 +16,17 @@ import { SpanStatusCode } from '@opentelemetry/api'; import { SelectableValue, TraceKeyValuePair, TraceSearchProps, TraceSearchTag } from '@grafana/data'; -import { KIND, LIBRARY_NAME, LIBRARY_VERSION, STATUS, STATUS_MESSAGE, TRACE_STATE, ID } from '../constants/span'; +import { + KIND, + LIBRARY_NAME, + LIBRARY_VERSION, + STATUS, + STATUS_MESSAGE, + TRACE_STATE, + ID, + SPAN_NAME, + SERVICE_NAME, +} from '../constants/span'; import TNil from '../types/TNil'; import { TraceSpan, CriticalPathSection } from '../types/trace'; @@ -46,13 +56,13 @@ const getAdhocFilterMatches = (spans: TraceSpan[], adhocFilters: Array { // Check that adhoc filter was created expect(result.current.search.adhocFilters).toHaveLength(1); expect(result.current.search.adhocFilters?.[0]).toMatchObject({ - key: 'serviceName', + key: 'service.name', operator: '=', value: 'my-service', }); @@ -120,7 +120,7 @@ describe('useSearch', () => { // Check that adhoc filter was created expect(result.current.search.adhocFilters).toHaveLength(1); expect(result.current.search.adhocFilters?.[0]).toMatchObject({ - key: 'spanName', + key: 'span.name', operator: '!=', value: 'my-operation', }); @@ -195,13 +195,13 @@ describe('useSearch', () => { // Verify each filter const filters = result.current.search.adhocFilters || []; - expect(filters.find((f) => f.key === 'serviceName')).toMatchObject({ - key: 'serviceName', + expect(filters.find((f) => f.key === 'service.name')).toMatchObject({ + key: 'service.name', operator: '=', value: 'my-service', }); - expect(filters.find((f) => f.key === 'spanName')).toMatchObject({ - key: 'spanName', + expect(filters.find((f) => f.key === 'span.name')).toMatchObject({ + key: 'span.name', operator: '!=', value: 'my-operation', }); @@ -306,7 +306,7 @@ describe('useSearch', () => { expect(result.current.search.adhocFilters).toHaveLength(5); const filters = result.current.search.adhocFilters || []; - expect(filters.find((f) => f.key === 'serviceName')?.operator).toBe('!='); + expect(filters.find((f) => f.key === 'service.name')?.operator).toBe('!='); expect(filters.find((f) => f.key === 'tag1')?.operator).toBe('='); expect(filters.find((f) => f.key === 'tag2')?.operator).toBe('!='); expect(filters.find((f) => f.key === 'tag3')?.operator).toBe('=~'); diff --git a/public/app/features/explore/TraceView/useSearch.ts b/public/app/features/explore/TraceView/useSearch.ts index 9deb191a8d3..086866b87fb 100644 --- a/public/app/features/explore/TraceView/useSearch.ts +++ b/public/app/features/explore/TraceView/useSearch.ts @@ -7,6 +7,7 @@ import { useDispatch, useSelector } from 'app/types/store'; import { DEFAULT_SPAN_FILTERS, randomId } from '../state/constants'; import { changePanelState } from '../state/explorePane'; +import { SPAN_NAME, SERVICE_NAME } from './components/constants/span'; import { TraceSpan, CriticalPathSection } from './components/types/trace'; import { filterSpans } from './components/utils/filter-spans'; @@ -25,7 +26,7 @@ export function migrateToAdhocFilters(search: TraceSearchProps): TraceSearchProp // Migrate serviceName if (search.serviceName && search.serviceName.trim() !== '') { adhocFilters.push({ - key: 'serviceName', + key: SERVICE_NAME, operator: search.serviceNameOperator || '=', value: search.serviceName, }); @@ -34,7 +35,7 @@ export function migrateToAdhocFilters(search: TraceSearchProps): TraceSearchProp // Migrate spanName if (search.spanName && search.spanName.trim() !== '') { adhocFilters.push({ - key: 'spanName', + key: SPAN_NAME, operator: search.spanNameOperator || '=', value: search.spanName, }); diff --git a/public/app/features/explore/TraceView/utils/tags.ts b/public/app/features/explore/TraceView/utils/tags.ts index 293326e359e..38ae3c92148 100644 --- a/public/app/features/explore/TraceView/utils/tags.ts +++ b/public/app/features/explore/TraceView/utils/tags.ts @@ -9,6 +9,8 @@ import { STATUS, STATUS_MESSAGE, TRACE_STATE, + SPAN_NAME, + SERVICE_NAME, } from '../components/constants/span'; import { Trace } from '../components/types/trace'; @@ -37,6 +39,11 @@ export const getTraceTagKeys = (trace: Trace) => { span.process.tags.forEach((tag) => { keys.push(tag.key); }); + + if (span.process.serviceName) { + keys.push(SERVICE_NAME); + } + if (span.logs !== null) { span.logs.forEach((log) => { log.fields.forEach((field) => { @@ -63,6 +70,9 @@ export const getTraceTagKeys = (trace: Trace) => { if (span.traceState) { keys.push(TRACE_STATE); } + if (span.operationName) { + keys.push(SPAN_NAME); + } keys.push(ID); }); keys = uniq(keys).sort(); @@ -93,6 +103,11 @@ export const getTraceTagValues = (trace: Trace, key: string) => { } switch (key) { + case SPAN_NAME: + if (span.operationName) { + values.push(span.operationName); + } + break; case KIND: if (span.kind) { values.push(span.kind); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 8c7bc95d901..25ad73abe1a 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -7641,39 +7641,6 @@ }, "share-span": "Share" }, - "span-filters": { - "aria-label-select-max-span-operator": "Select max span operator", - "aria-label-select-min-span-operator": "Select min span operator", - "aria-label-select-service-name": "Select service name", - "aria-label-select-service-name-operator": "Select service name operator", - "aria-label-select-span-name": "Select span name", - "aria-label-select-span-name-operator": "Select span name operator", - "ariaLabel-select-max-span-duration": "Select max span duration", - "ariaLabel-select-min-span-duration": "Select min span duration", - "label-collapse": "Span Filters", - "label-duration": "Duration", - "label-service-name": "Service name", - "label-span-name": "Span name", - "label-tags": "Tags", - "placeholder-all-service-names": "All service names", - "placeholder-all-span-names": "All span names", - "tooltip-collapse": "Filter your spans below. You can continue to apply filters until you have narrowed down your resulting spans to the select few you are most interested in.", - "tooltip-duration": "Filter by duration. Accepted units are {{units}}", - "tooltip-tags": "Filter by tags, process tags or log fields in your spans." - }, - "span-filters-tags": { - "aria-label-add-tag": "Add tag", - "aria-label-input-tag-value": "Input tag value", - "aria-label-remove-tag": "Remove tag", - "aria-label-select-tag-key": "Select tag key", - "aria-label-select-tag-operator": "Select tag operator", - "aria-label-select-tag-value": "Select tag value", - "placeholder-select-tag": "Select tag", - "placeholder-select-value": "Select value", - "placeholder-tag-value": "Tag value", - "tooltip-add-tag": "Add tag", - "tooltip-remove-tag": "Remove tag" - }, "span-flame-graph": { "flame-graph": "Flame graph" }, From 90af2c3c3b6c28e66506e0e67f7acd77959efbe9 Mon Sep 17 00:00:00 2001 From: Rafael Bortolon Paulovic Date: Thu, 18 Dec 2025 14:11:47 +0100 Subject: [PATCH 015/229] fix(dashboard): panic on nil logger on dashboard accessor (#115545) fix(dashboard): fix panic on log --- pkg/registry/apis/dashboard/legacy/sql_dashboards.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go index 0a496e4667b..0b7ffa04ea4 100644 --- a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go +++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go @@ -113,6 +113,7 @@ func ProvideMigratorDashboardAccessor( dashboardPermissionSvc: nil, // not needed for migration libraryPanelSvc: nil, // not needed for migration accessControl: accessControl, + log: log.New("legacy.dashboard.migrator.accessor"), } } @@ -136,6 +137,7 @@ func NewDashboardSQLAccess(sql legacysql.LegacyDatabaseProvider, dashboardPermissionSvc: dashboardPermissionSvc, libraryPanelSvc: libraryPanelSvc, accessControl: accessControl, + log: log.New("legacy.dashboard.accessor"), } } From 14ef6ca4eb265bf19fddf669ab5d7c982116b00d Mon Sep 17 00:00:00 2001 From: Mariell Hoversholm Date: Thu, 18 Dec 2025 14:23:07 +0100 Subject: [PATCH 016/229] docs: remove SECURITY.md (#115549) --- .github/CODEOWNERS | 1 - SECURITY.md | 29 ----------------------------- 2 files changed, 30 deletions(-) delete mode 100644 SECURITY.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7ef6be6644c..4cac8f6dd81 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -24,7 +24,6 @@ /NOTICE.md @torkelo /README.md @grafana/docs-grafana /ROADMAP.md @torkelo -/SECURITY.md @grafana/security-team /SUPPORT.md @torkelo /WORKFLOW.md @torkelo /contribute/ @grafana/grafana-community-support diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 42c58f9cd67..00000000000 --- a/SECURITY.md +++ /dev/null @@ -1,29 +0,0 @@ -# Reporting security issues - -If you think you have found a security vulnerability, we have two routes for reporting security issues. - -Important: Whichever route you choose, we ask you to not disclose the vulnerability before it has been fixed and announced, unless you received a response from the Grafana Labs security team that you can do so. - -[Full guidance on reporting a security issue can be found here](https://grafana.com/legal/report-a-security-issue/). - -This product is in scope for our Bug Bounty Program. To submit a vulnerability report, please visit [Grafana Labs Bug Bounty page](https://app.intigriti.com/programs/grafanalabs/grafanaossbbp/detail) and follow the instructions provided. Our security team will review your submission and get back to you as soon as possible. - ---- - -For products and services outside the scope of our bug bounty program, or if you do not wish to receive a bounty, you can report issues directly to us via email at security@grafana.com. This address can be used for all of Grafana Labs’ open source and commercial products (including but not limited to Grafana, Grafana Cloud, Grafana Enterprise, and grafana.com). - -Please encrypt your message to us; please use our PGP key. The key fingerprint is: - -225E 6A9B BB15 A37E 95EB 6312 C66A 51CC B44C 27E0 - -The key is available from [keyserver.ubuntu.com](https://keyserver.ubuntu.com/pks/lookup?search=0x225E6A9BBB15A37E95EB6312C66A51CCB44C27E0&fingerprint=on&op=index). - -Grafana Labs will send you a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance. - -**Important:** We ask you to not disclose the vulnerability before it have been fixed and announced, unless you received a response from the Grafana Labs security team that you can do so. - -## Security announcements - -We will post a summary, remediation, and mitigation details for any patch containing security fixes on the Grafana blog. The security announcement blog posts will be tagged with the [security tag](https://grafana.com/tags/security/). - -You can also track security announcements via the [RSS feed](https://grafana.com/tags/security/index.xml). From 39fa6559ee01a5250b9fbc15c63ba63215af9bf3 Mon Sep 17 00:00:00 2001 From: Kevin Minehart <5140827+kminehart@users.noreply.github.com> Date: Thu, 18 Dec 2025 14:46:24 +0100 Subject: [PATCH 017/229] CI: Remove the default alpine & ubuntu versions so that the ones in Dockerfile (#115544) * Remove the default alpine & ubuntu versions so that the ones in Dockerfile are used * set default to just 'alpine' or 'ubuntu' * use defaults instead --- .github/actions/build-package/action.yml | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/.github/actions/build-package/action.yml b/.github/actions/build-package/action.yml index dd5aaa13650..978c6645240 100644 --- a/.github/actions/build-package/action.yml +++ b/.github/actions/build-package/action.yml @@ -82,14 +82,6 @@ inputs: description: Docker registry of produced images default: docker.io required: false - ubuntu-base: - type: string - default: 'ubuntu:22.04' - required: false - alpine-base: - type: string - default: 'alpine:3.22' - required: false outputs: dist-dir: description: Directory where artifacts are placed @@ -134,13 +126,11 @@ runs: UBUNTU_TAG_FORMAT: ${{ inputs.docker-tag-format-ubuntu }} CHECKSUM: ${{ inputs.checksum }} VERIFY: ${{ inputs.verify }} - ALPINE_BASE: ${{ inputs.alpine-base }} - UBUNTU_BASE: ${{ inputs.ubuntu-base }} with: verb: run dagger-flags: --verbose=0 version: 0.18.8 - args: go run -C ${GRAFANA_PATH} ./pkg/build/cmd artifacts --artifacts ${ARTIFACTS} --grafana-dir=${GRAFANA_PATH} --alpine-base=${ALPINE_BASE} --ubuntu-base=${UBUNTU_BASE} --enterprise-dir=${ENTERPRISE_PATH} --version=${VERSION} --patches-repo=${PATCHES_REPO} --patches-ref=${PATCHES_REF} --patches-path=${PATCHES_PATH} --build-id=${BUILD_ID} --tag-format="${TAG_FORMAT}" --ubuntu-tag-format="${UBUNTU_TAG_FORMAT}" --org=${DOCKER_ORG} --registry=${DOCKER_REGISTRY} --checksum=${CHECKSUM} --verify=${VERIFY} > $OUTFILE + args: go run -C ${GRAFANA_PATH} ./pkg/build/cmd artifacts --artifacts ${ARTIFACTS} --grafana-dir=${GRAFANA_PATH} --enterprise-dir=${ENTERPRISE_PATH} --version=${VERSION} --patches-repo=${PATCHES_REPO} --patches-ref=${PATCHES_REF} --patches-path=${PATCHES_PATH} --build-id=${BUILD_ID} --tag-format="${TAG_FORMAT}" --ubuntu-tag-format="${UBUNTU_TAG_FORMAT}" --org=${DOCKER_ORG} --registry=${DOCKER_REGISTRY} --checksum=${CHECKSUM} --verify=${VERIFY} > $OUTFILE - id: output shell: bash env: From 5c7cdabaa39c26b4024d114f565d05077f72fa39 Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Thu, 18 Dec 2025 14:58:39 +0100 Subject: [PATCH 018/229] Alerting: Improve performance of rule list view with limit_alerts=0 (#115548) Alerting: Improve performance of rule list view --- .../ngalert/api/prometheus/api_prometheus.go | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/pkg/services/ngalert/api/prometheus/api_prometheus.go b/pkg/services/ngalert/api/prometheus/api_prometheus.go index 934805d74f4..b4e14a66cfe 100644 --- a/pkg/services/ngalert/api/prometheus/api_prometheus.go +++ b/pkg/services/ngalert/api/prometheus/api_prometheus.go @@ -357,7 +357,7 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *contextmodel.ReqContext) respon type RuleStatusMutator func(source *ngmodels.AlertRule, toMutate *apimodels.AlertingRule) // mutator function used to attach alert states to the rule and returns the totals and filtered totals -type RuleAlertStateMutator func(source *ngmodels.AlertRule, toMutate *apimodels.AlertingRule, stateFilterSet map[eval.State]struct{}, matchers labels.Matchers, labelOptions []ngmodels.LabelOption) (total map[string]int64, filteredTotal map[string]int64) +type RuleAlertStateMutator func(source *ngmodels.AlertRule, toMutate *apimodels.AlertingRule, stateFilterSet map[eval.State]struct{}, matchers labels.Matchers, labelOptions []ngmodels.LabelOption, limitAlerts int64) (total map[string]int64, filteredTotal map[string]int64) func RuleStatusMutatorGenerator(statusReader StatusReader) RuleStatusMutator { return func(source *ngmodels.AlertRule, toMutate *apimodels.AlertingRule) { @@ -377,32 +377,18 @@ func RuleStatusMutatorGenerator(statusReader StatusReader) RuleStatusMutator { } func RuleAlertStateMutatorGenerator(manager state.AlertInstanceManager) RuleAlertStateMutator { - return func(source *ngmodels.AlertRule, toMutate *apimodels.AlertingRule, stateFilterSet map[eval.State]struct{}, matchers labels.Matchers, labelOptions []ngmodels.LabelOption) (map[string]int64, map[string]int64) { + return func(source *ngmodels.AlertRule, toMutate *apimodels.AlertingRule, stateFilterSet map[eval.State]struct{}, matchers labels.Matchers, labelOptions []ngmodels.LabelOption, limitAlerts int64) (map[string]int64, map[string]int64) { states := manager.GetStatesForRuleUID(source.OrgID, source.UID) totals := make(map[string]int64) totalsFiltered := make(map[string]int64) for _, alertState := range states { activeAt := alertState.StartsAt - valString := "" - if alertState.State == eval.Alerting || alertState.State == eval.Pending || alertState.State == eval.Recovering { - valString = FormatValues(alertState) - } stateKey := strings.ToLower(alertState.State.String()) totals[stateKey] += 1 // Do not add error twice when execution error state is Error if alertState.Error != nil && source.ExecErrState != ngmodels.ErrorErrState { totals["error"] += 1 } - alert := apimodels.Alert{ - Labels: apimodels.LabelsFromMap(alertState.GetLabels(labelOptions...)), - Annotations: apimodels.LabelsFromMap(alertState.Annotations), - - // TODO: or should we make this two fields? Using one field lets the - // frontend use the same logic for parsing text on annotations and this. - State: state.FormatStateAndReason(alertState.State, alertState.StateReason), - ActiveAt: &activeAt, - Value: valString, - } // Set the state of the rule based on the state of its alerts. // Only update the rule state with 'pending' or 'recovering' if the current state is 'inactive'. @@ -442,7 +428,23 @@ func RuleAlertStateMutatorGenerator(manager state.AlertInstanceManager) RuleAler totalsFiltered["error"] += 1 } - toMutate.Alerts = append(toMutate.Alerts, alert) + if limitAlerts != 0 { + valString := "" + if alertState.State == eval.Alerting || alertState.State == eval.Pending || alertState.State == eval.Recovering { + valString = FormatValues(alertState) + } + + toMutate.Alerts = append(toMutate.Alerts, apimodels.Alert{ + Labels: apimodels.LabelsFromMap(alertState.GetLabels(labelOptions...)), + Annotations: apimodels.LabelsFromMap(alertState.Annotations), + + // TODO: or should we make this two fields? Using one field lets the + // frontend use the same logic for parsing text on annotations and this. + State: state.FormatStateAndReason(alertState.State, alertState.StateReason), + ActiveAt: &activeAt, + Value: valString, + }) + } } return totals, totalsFiltered } @@ -1227,7 +1229,7 @@ func toRuleGroup(log log.Logger, groupKey ngmodels.AlertRuleGroupKey, folderFull } // mutate rule for alert states - totals, totalsFiltered := ruleAlertStateMutator(rule, &alertingRule, stateFilterSet, matchers, labelOptions) + totals, totalsFiltered := ruleAlertStateMutator(rule, &alertingRule, stateFilterSet, matchers, labelOptions, limitAlerts) if alertingRule.State != "" { rulesTotals[alertingRule.State] += 1 From 4fbcebac2c78c48b4338881b42b5c39db7c8b9c5 Mon Sep 17 00:00:00 2001 From: "Marc M." <146180665+grafakus@users.noreply.github.com> Date: Thu, 18 Dec 2025 15:01:04 +0100 Subject: [PATCH 019/229] Deps: Upgrade Scenes to v6.51.0 (#115547) Scenes: Upgrade to v6.51.0 --- package.json | 4 ++-- yarn.lock | 22 +++++++++++----------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index f2300869740..37dc6ff745c 100644 --- a/package.json +++ b/package.json @@ -295,8 +295,8 @@ "@grafana/plugin-ui": "^0.11.1", "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", - "@grafana/scenes": "6.50.0", - "@grafana/scenes-react": "6.50.0", + "@grafana/scenes": "^6.51.0", + "@grafana/scenes-react": "^6.51.0", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", diff --git a/yarn.lock b/yarn.lock index e20676b04f0..a60a9c08912 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3604,11 +3604,11 @@ __metadata: languageName: unknown linkType: soft -"@grafana/scenes-react@npm:6.50.0": - version: 6.50.0 - resolution: "@grafana/scenes-react@npm:6.50.0" +"@grafana/scenes-react@npm:^6.51.0": + version: 6.51.0 + resolution: "@grafana/scenes-react@npm:6.51.0" dependencies: - "@grafana/scenes": "npm:6.50.0" + "@grafana/scenes": "npm:6.51.0" lru-cache: "npm:^10.2.2" react-use: "npm:^17.4.0" peerDependencies: @@ -3620,7 +3620,7 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/9ac9f8a32699f447c7b67dd2aef4e3ca5bc9fc98e94e0dc139e7824274ffa005b7fb3fc42ca5e55bdf89b91e3af0d3807b03e1a261db91c65717ee1763e5e807 + checksum: 10/14acdfe5220e67e7450780320b779e2e4a255995d55f0c82eb0d25933e72598e54826df0a8beee05591efe01a91ddab840483fea3bb828bd5925c3f0b44b8d17 languageName: node linkType: hard @@ -3650,9 +3650,9 @@ __metadata: languageName: node linkType: hard -"@grafana/scenes@npm:6.50.0": - version: 6.50.0 - resolution: "@grafana/scenes@npm:6.50.0" +"@grafana/scenes@npm:6.51.0, @grafana/scenes@npm:^6.51.0": + version: 6.51.0 + resolution: "@grafana/scenes@npm:6.51.0" dependencies: "@floating-ui/react": "npm:^0.26.16" "@leeoniya/ufuzzy": "npm:^1.0.16" @@ -3672,7 +3672,7 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/7bc6280ff065bbba37f010e2a1f0a7dc998fe43721ddc0121e27a754c41e824b82a44222100282a69143a52061cf0dce39e6bc8b95292ca444a59c114d4b5a41 + checksum: 10/4e4f43babe786ff729d58b7636182df57c58ce40c13b56036f725c070e0cf597cbe52aaa0f811184b8d42d8d1f9a32679695471d410f883051b09da44f8bf36a languageName: node linkType: hard @@ -19508,8 +19508,8 @@ __metadata: "@grafana/plugin-ui": "npm:^0.11.1" "@grafana/prometheus": "workspace:*" "@grafana/runtime": "workspace:*" - "@grafana/scenes": "npm:6.50.0" - "@grafana/scenes-react": "npm:6.50.0" + "@grafana/scenes": "npm:^6.51.0" + "@grafana/scenes-react": "npm:^6.51.0" "@grafana/schema": "workspace:*" "@grafana/sql": "workspace:*" "@grafana/test-utils": "workspace:*" From f1b19dd9faccc5e868c18c5ae7def8cea7438dd8 Mon Sep 17 00:00:00 2001 From: Andreas Christou Date: Thu, 18 Dec 2025 15:38:15 +0100 Subject: [PATCH 020/229] ElasticSearch: Update annotation time-range properties (#115500) Update time-range properties --- .../plugins/datasource/elasticsearch/datasource.test.ts | 6 +++--- public/app/plugins/datasource/elasticsearch/datasource.ts | 8 ++++---- public/app/plugins/datasource/elasticsearch/types.ts | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/public/app/plugins/datasource/elasticsearch/datasource.test.ts b/public/app/plugins/datasource/elasticsearch/datasource.test.ts index 33c3486bd31..6e3c7b2c2ce 100644 --- a/public/app/plugins/datasource/elasticsearch/datasource.test.ts +++ b/public/app/plugins/datasource/elasticsearch/datasource.test.ts @@ -1000,7 +1000,7 @@ describe('ElasticDatasource', () => { }); expect(postResourceRequestMock).toHaveBeenCalledWith( '_msearch', - '{"search_type":"query_then_fetch","ignore_unavailable":true,"index":"[test-]YYYY.MM.DD"}\n{"query":{"bool":{"filter":[{"bool":{"should":[{"range":{"@test_time":{"from":1683291160012,"to":1683291460012,"format":"epoch_millis"}}},{"range":{"@time_end_field":{"from":1683291160012,"to":1683291460012,"format":"epoch_millis"}}}],"minimum_should_match":1}},{"query_string":{"query":"abc"}}]}},"size":10000}\n' + '{"search_type":"query_then_fetch","ignore_unavailable":true,"index":"[test-]YYYY.MM.DD"}\n{"query":{"bool":{"filter":[{"bool":{"should":[{"range":{"@test_time":{"gte":1683291160012,"lte":1683291460012,"format":"epoch_millis"}}},{"range":{"@time_end_field":{"gte":1683291160012,"lte":1683291460012,"format":"epoch_millis"}}}],"minimum_should_match":1}},{"query_string":{"query":"abc"}}]}},"size":10000}\n' ); }); @@ -1030,7 +1030,7 @@ describe('ElasticDatasource', () => { }); expect(postResourceRequestMock).toHaveBeenCalledWith( '_msearch', - '{"search_type":"query_then_fetch","ignore_unavailable":true,"index":"[test-]YYYY.MM.DD"}\n{"query":{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"from":1683291160012,"to":1683291460012,"format":"epoch_millis"}}}],"minimum_should_match":1}}]}},"size":10000}\n' + '{"search_type":"query_then_fetch","ignore_unavailable":true,"index":"[test-]YYYY.MM.DD"}\n{"query":{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"gte":1683291160012,"lte":1683291460012,"format":"epoch_millis"}}}],"minimum_should_match":1}}]}},"size":10000}\n' ); }); @@ -1087,7 +1087,7 @@ describe('ElasticDatasource', () => { }); expect(postResourceRequestMock).toHaveBeenCalledWith( '_msearch', - '{"search_type":"query_then_fetch","ignore_unavailable":true,"index":"[test-]YYYY.MM.DD"}\n{"query":{"bool":{"filter":[{"bool":{"should":[{"range":{"@test_time":{"from":1683291160012,"to":1683291460012,"format":"epoch_millis"}}},{"range":{"@time_end_field":{"from":1683291160012,"to":1683291460012,"format":"epoch_millis"}}}],"minimum_should_match":1}},{"query_string":{"query":"abc AND abc_key:\\"abc_value\\""}}]}},"size":10000}\n' + '{"search_type":"query_then_fetch","ignore_unavailable":true,"index":"[test-]YYYY.MM.DD"}\n{"query":{"bool":{"filter":[{"bool":{"should":[{"range":{"@test_time":{"gte":1683291160012,"lte":1683291460012,"format":"epoch_millis"}}},{"range":{"@time_end_field":{"gte":1683291160012,"lte":1683291460012,"format":"epoch_millis"}}}],"minimum_should_match":1}},{"query_string":{"query":"abc AND abc_key:\\"abc_value\\""}}]}},"size":10000}\n' ); }); }); diff --git a/public/app/plugins/datasource/elasticsearch/datasource.ts b/public/app/plugins/datasource/elasticsearch/datasource.ts index d9294e5e62e..95e329e03ea 100644 --- a/public/app/plugins/datasource/elasticsearch/datasource.ts +++ b/public/app/plugins/datasource/elasticsearch/datasource.ts @@ -294,8 +294,8 @@ export class ElasticDatasource const dateRanges = []; const rangeStart: RangeMap = {}; rangeStart[timeField] = { - from: options.range.from.valueOf(), - to: options.range.to.valueOf(), + gte: options.range.from.valueOf(), + lte: options.range.to.valueOf(), format: 'epoch_millis', }; dateRanges.push({ range: rangeStart }); @@ -303,8 +303,8 @@ export class ElasticDatasource if (timeEndField) { const rangeEnd: RangeMap = {}; rangeEnd[timeEndField] = { - from: options.range.from.valueOf(), - to: options.range.to.valueOf(), + gte: options.range.from.valueOf(), + lte: options.range.to.valueOf(), format: 'epoch_millis', }; dateRanges.push({ range: rangeEnd }); diff --git a/public/app/plugins/datasource/elasticsearch/types.ts b/public/app/plugins/datasource/elasticsearch/types.ts index c3a7c5f9da2..4645a2a824f 100644 --- a/public/app/plugins/datasource/elasticsearch/types.ts +++ b/public/app/plugins/datasource/elasticsearch/types.ts @@ -137,7 +137,7 @@ export interface ElasticsearchAnnotationQuery { index?: string; } -export type RangeMap = Record; +export type RangeMap = Record; export type ElasticsearchResponse = ElasticsearchResponseWithHits | ElasticsearchResponseWithAggregations; From 4bcd31b17acb8f6d8af55452078a914863077282 Mon Sep 17 00:00:00 2001 From: Kristina Demeshchik Date: Thu, 18 Dec 2025 09:59:26 -0500 Subject: [PATCH 021/229] Dashboard: change export dropdown placement in sidebar (#115515) Update export menu placement --- .../dashboard-scene/edit-pane/DashboardExportButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardExportButton.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardExportButton.tsx index 35d8afbdb4d..e048a016868 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardExportButton.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardExportButton.tsx @@ -18,7 +18,7 @@ const newExportButtonSelector = selectors.pages.Dashboard.DashNav.NewExportButto export function ShareExportDashboardButton({ dashboard }: Props) { return ( - } placement="left-end"> + } placement="left-start"> Date: Thu, 18 Dec 2025 09:59:58 -0500 Subject: [PATCH 022/229] Dashboards: Fix text panel content loss during v1 to v2 migration (#115496) * move content and mode properties to options level * move to angular section * Update comments * handle missing angular text panel * re-generate test files * angualr panels tests * fixing test * Update output files * Update output for dev dashboard * Spread options at the top panel level for migration * linting issue --------- Co-authored-by: Ivan Ortega --- .../migrations/v0alpha1.migrations.v42.json | 12 +- ...-v16.grid_layout_upgrade.v42.v2alpha1.json | 9 +- ...g-v16.grid_layout_upgrade.v42.v2beta1.json | 9 +- ...g-v2.panels-and-services.v42.v2alpha1.json | 15 +- ...ig-v2.panels-and-services.v42.v2beta1.json | 15 +- ...a1-mig-v24.table-angular.v42.v2alpha1.json | 24 +- ...ta1-mig-v24.table-angular.v42.v2beta1.json | 24 +- ...a1-mig-v26.text2_to_text.v42.v2alpha1.json | 10 +- ...ta1-mig-v26.text2_to_text.v42.v2beta1.json | 10 +- ...lpha1.testdata-datalinks.v42.v2alpha1.json | 10 +- ...alpha1.testdata-datalinks.v42.v2beta1.json | 10 +- ...sted-variables-drilldown.v42.v2alpha1.json | 10 +- ...ested-variables-drilldown.v42.v2beta1.json | 10 +- ...estdata-nested-variables.v42.v2alpha1.json | 10 +- ...testdata-nested-variables.v42.v2beta1.json | 10 +- .../v0alpha1.migrations.v42.v1beta1.json | 11 +- .../v0alpha1.migrations.v42.v2alpha1.json | 14 +- .../v0alpha1.migrations.v42.v2beta1.json | 14 +- .../v0alpha1.gauge_tests.v42.v2alpha1.json | 72 + .../v0alpha1.gauge_tests.v42.v2beta1.json | 72 + .../v0alpha1.graph_tests.v42.v2alpha1.json | 72 +- .../v0alpha1.graph_tests.v42.v2beta1.json | 72 +- .../v0alpha1.heatmap-legacy.v42.v2alpha1.json | 238 +- .../v0alpha1.heatmap-legacy.v42.v2beta1.json | 238 +- .../v0alpha1.polystat_test.v42.v2alpha1.json | 3277 ++++++++++++++++- .../v0alpha1.polystat_test.v42.v2beta1.json | 3277 ++++++++++++++++- ...beta1.v1beta1.all-panels.v42.v2alpha1.json | 37 +- ...1beta1.v1beta1.all-panels.v42.v2beta1.json | 37 +- ...ha1.v1beta1.v1beta1.home.v42.v2alpha1.json | 50 + ...pha1.v1beta1.v1beta1.home.v42.v2beta1.json | 50 + .../conversion/v1beta1_to_v2alpha1.go | 6 +- .../migrations/migrations.v42.json | 11 +- .../dev-dashboards/migrations/migrations.json | 12 +- eslint-suppressions.json | 5 - .../serialization/angularMigration.test.ts | 151 +- .../serialization/angularMigration.ts | 38 +- .../dashboard/api/ResponseTransformers.ts | 16 +- 37 files changed, 7835 insertions(+), 123 deletions(-) diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/migrations/v0alpha1.migrations.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/migrations/v0alpha1.migrations.v42.json index 89ce80876d2..e4e6f8831da 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/migrations/v0alpha1.migrations.v42.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/migrations/v0alpha1.migrations.v42.json @@ -300,15 +300,9 @@ "y": 0 }, "id": 6, - "options": { - "code": { - "language": "plaintext", - "showLineNumbers": false, - "showMiniMap": false - }, - "content": "# Graph panel \u003e\u003e Timeseries panel\n\nKnown issues:\n* hiding null/empty series\n* time regions", - "mode": "markdown" - }, + "options": {}, + "content": "# Graph panel \u003e\u003e Timeseries panel\n\nKnown issues:\n* hiding null/empty series\n* time regions", + "mode": "markdown", "pluginVersion": "11.0.0-pre", "targets": [ { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v16.grid_layout_upgrade.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v16.grid_layout_upgrade.v42.v2alpha1.json index 7229c6df268..c4198fa24b9 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v16.grid_layout_upgrade.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v16.grid_layout_upgrade.v42.v2alpha1.json @@ -115,7 +115,14 @@ "kind": "logs", "spec": { "pluginVersion": "", - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "logs", + "originalOptions": { + "height": 100 + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] diff --git a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v16.grid_layout_upgrade.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v16.grid_layout_upgrade.v42.v2beta1.json index efdbb91f745..578e9d707ad 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v16.grid_layout_upgrade.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v16.grid_layout_upgrade.v42.v2beta1.json @@ -120,7 +120,14 @@ "group": "logs", "version": "", "spec": { - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "logs", + "originalOptions": { + "height": 100 + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] diff --git a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v2.panels-and-services.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v2.panels-and-services.v42.v2alpha1.json index 57b7b24ed56..d30c3ce7923 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v2.panels-and-services.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v2.panels-and-services.v42.v2alpha1.json @@ -182,7 +182,20 @@ "kind": "table", "spec": { "pluginVersion": "", - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "table", + "originalOptions": { + "grid": { + "max": 100, + "min": 0 + }, + "legend": true, + "y2_format": "bytes", + "y_format": "short" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] diff --git a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v2.panels-and-services.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v2.panels-and-services.v42.v2beta1.json index 0143563e75b..6a02d64f5b2 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v2.panels-and-services.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v2.panels-and-services.v42.v2beta1.json @@ -189,7 +189,20 @@ "group": "table", "version": "", "spec": { - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "table", + "originalOptions": { + "grid": { + "max": 100, + "min": 0 + }, + "legend": true, + "y2_format": "bytes", + "y_format": "short" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] diff --git a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v24.table-angular.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v24.table-angular.v42.v2alpha1.json index 4734298869d..6fa76798215 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v24.table-angular.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v24.table-angular.v42.v2alpha1.json @@ -435,7 +435,29 @@ "kind": "table", "spec": { "pluginVersion": "", - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "table", + "originalOptions": { + "styles": [ + { + "colors": [ + "red", + "yellow", + "green" + ], + "pattern": "/.*/", + "thresholds": [ + "10", + "20" + ], + "unit": "short" + } + ], + "table": "table2" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] diff --git a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v24.table-angular.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v24.table-angular.v42.v2beta1.json index 57979e9f551..9ae87fe0f32 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v24.table-angular.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v24.table-angular.v42.v2beta1.json @@ -449,7 +449,29 @@ "group": "table", "version": "", "spec": { - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "table", + "originalOptions": { + "styles": [ + { + "colors": [ + "red", + "yellow", + "green" + ], + "pattern": "/.*/", + "thresholds": [ + "10", + "20" + ], + "unit": "short" + } + ], + "table": "table2" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] diff --git a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v26.text2_to_text.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v26.text2_to_text.v42.v2alpha1.json index d15f179ac31..c7298296064 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v26.text2_to_text.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v26.text2_to_text.v42.v2alpha1.json @@ -110,7 +110,15 @@ "kind": "text", "spec": { "pluginVersion": "", - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "text", + "originalOptions": { + "content": "# Angular Text Panel\n# $constant\n\nFor markdown syntax help: [commonmark.org/help](https://commonmark.org/help/)\n\n## $text\n\n", + "mode": "markdown" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] diff --git a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v26.text2_to_text.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v26.text2_to_text.v42.v2beta1.json index 59aad2fdb0b..db2cfcb916e 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v26.text2_to_text.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v26.text2_to_text.v42.v2beta1.json @@ -115,7 +115,15 @@ "group": "text", "version": "", "spec": { - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "text", + "originalOptions": { + "content": "# Angular Text Panel\n# $constant\n\nFor markdown syntax help: [commonmark.org/help](https://commonmark.org/help/)\n\n## $text\n\n", + "mode": "markdown" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-datalinks.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-datalinks.v42.v2alpha1.json index 6eb2f2493aa..4a9e24d85f6 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-datalinks.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-datalinks.v42.v2alpha1.json @@ -361,7 +361,15 @@ "kind": "text", "spec": { "pluginVersion": "", - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "text", + "originalOptions": { + "content": "## Data link variables overview\n\nThis dashboard presents variables that one can use when creating *data links*. All links redirect to this dashboard and this panel represents the values that were interpolated in the link that was clicked.\n\n\n#### Series variables\n1. **Name:** \u003cspan style=\"color: orange;\"\u003e$seriesName\u003c/span\u003e\n2. **label.datacenter:** \u003cspan style=\"color: orange;\"\u003e$labelDatacenter\u003c/span\u003e\n3. **label.datacenter.region:** \u003cspan style=\"color: orange;\"\u003e$labelDatacenterRegion\u003c/span\u003e\n\n#### Field variables\n1. **Name:** \u003cspan style=\"color: orange;\"\u003e$fieldName\u003c/span\u003e\n\n#### Value variables\n1. **Time:** \u003cspan style=\"color: orange;\"\u003e$valueTime\u003c/span\u003e\n2. **Numeric:** \u003cspan style=\"color: orange;\"\u003e$valueNumeric\u003c/span\u003e\n3. **Text:** \u003cspan style=\"color: orange;\"\u003e$valueText\u003c/span\u003e\n4. **Calc:** \u003cspan style=\"color: orange;\"\u003e$valueCalc\u003c/span\u003e\n\n", + "mode": "markdown" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-datalinks.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-datalinks.v42.v2beta1.json index 22814d61a1e..62b30aa6480 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-datalinks.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-datalinks.v42.v2beta1.json @@ -372,7 +372,15 @@ "group": "text", "version": "", "spec": { - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "text", + "originalOptions": { + "content": "## Data link variables overview\n\nThis dashboard presents variables that one can use when creating *data links*. All links redirect to this dashboard and this panel represents the values that were interpolated in the link that was clicked.\n\n\n#### Series variables\n1. **Name:** \u003cspan style=\"color: orange;\"\u003e$seriesName\u003c/span\u003e\n2. **label.datacenter:** \u003cspan style=\"color: orange;\"\u003e$labelDatacenter\u003c/span\u003e\n3. **label.datacenter.region:** \u003cspan style=\"color: orange;\"\u003e$labelDatacenterRegion\u003c/span\u003e\n\n#### Field variables\n1. **Name:** \u003cspan style=\"color: orange;\"\u003e$fieldName\u003c/span\u003e\n\n#### Value variables\n1. **Time:** \u003cspan style=\"color: orange;\"\u003e$valueTime\u003c/span\u003e\n2. **Numeric:** \u003cspan style=\"color: orange;\"\u003e$valueNumeric\u003c/span\u003e\n3. **Text:** \u003cspan style=\"color: orange;\"\u003e$valueText\u003c/span\u003e\n4. **Calc:** \u003cspan style=\"color: orange;\"\u003e$valueCalc\u003c/span\u003e\n\n", + "mode": "markdown" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables-drilldown.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables-drilldown.v42.v2alpha1.json index a469d4020e5..f754cbd766a 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables-drilldown.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables-drilldown.v42.v2alpha1.json @@ -167,7 +167,15 @@ "kind": "text", "spec": { "pluginVersion": "", - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "text", + "originalOptions": { + "content": "## Data center = $datacenter\n\n### server = $server\n\n#### pod = $pod", + "mode": "markdown" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables-drilldown.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables-drilldown.v42.v2beta1.json index 0a46a76d48e..8a8cffdf465 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables-drilldown.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables-drilldown.v42.v2beta1.json @@ -174,7 +174,15 @@ "group": "text", "version": "", "spec": { - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "text", + "originalOptions": { + "content": "## Data center = $datacenter\n\n### server = $server\n\n#### pod = $pod", + "mode": "markdown" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2alpha1.json index ecc156114f4..89857905689 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2alpha1.json @@ -273,7 +273,15 @@ "kind": "text", "spec": { "pluginVersion": "", - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "text", + "originalOptions": { + "content": "## Data center = $datacenter\n\n### server = $server\n\n#### pod = $pod", + "mode": "markdown" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2beta1.json index 54d6e9efa1c..13320b47904 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2beta1.json @@ -282,7 +282,15 @@ "group": "text", "version": "", "spec": { - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "text", + "originalOptions": { + "content": "## Data center = $datacenter\n\n### server = $server\n\n#### pod = $pod", + "mode": "markdown" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/migrations/v0alpha1.migrations.v42.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/migrations/v0alpha1.migrations.v42.v1beta1.json index 5ae52076acc..7542cf17652 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/migrations/v0alpha1.migrations.v42.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/migrations/v0alpha1.migrations.v42.v1beta1.json @@ -296,6 +296,7 @@ } }, { + "content": "# Graph panel \u003e\u003e Timeseries panel\n\nKnown issues:\n* hiding null/empty series\n* time regions", "datasource": { "type": "grafana-testdata-datasource" }, @@ -306,15 +307,7 @@ "y": 0 }, "id": 6, - "options": { - "code": { - "language": "plaintext", - "showLineNumbers": false, - "showMiniMap": false - }, - "content": "# Graph panel \u003e\u003e Timeseries panel\n\nKnown issues:\n* hiding null/empty series\n* time regions", - "mode": "markdown" - }, + "mode": "markdown", "pluginVersion": "11.0.0-pre", "targets": [ { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/migrations/v0alpha1.migrations.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/migrations/v0alpha1.migrations.v42.v2alpha1.json index 3c2a1a4f9d7..21d45e91f10 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/migrations/v0alpha1.migrations.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/migrations/v0alpha1.migrations.v42.v2alpha1.json @@ -1256,13 +1256,13 @@ "spec": { "pluginVersion": "11.0.0-pre", "options": { - "code": { - "language": "plaintext", - "showLineNumbers": false, - "showMiniMap": false - }, - "content": "# Graph panel \u003e\u003e Timeseries panel\n\nKnown issues:\n* hiding null/empty series\n* time regions", - "mode": "markdown" + "__angularMigration": { + "autoMigrateFrom": "text", + "originalOptions": { + "content": "# Graph panel \u003e\u003e Timeseries panel\n\nKnown issues:\n* hiding null/empty series\n* time regions", + "mode": "markdown" + } + } }, "fieldConfig": { "defaults": {}, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/migrations/v0alpha1.migrations.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/migrations/v0alpha1.migrations.v42.v2beta1.json index c6a9186b9e0..ed7123f5e38 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/migrations/v0alpha1.migrations.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/migrations/v0alpha1.migrations.v42.v2beta1.json @@ -1301,13 +1301,13 @@ "version": "11.0.0-pre", "spec": { "options": { - "code": { - "language": "plaintext", - "showLineNumbers": false, - "showMiniMap": false - }, - "content": "# Graph panel \u003e\u003e Timeseries panel\n\nKnown issues:\n* hiding null/empty series\n* time regions", - "mode": "markdown" + "__angularMigration": { + "autoMigrateFrom": "text", + "originalOptions": { + "content": "# Graph panel \u003e\u003e Timeseries panel\n\nKnown issues:\n* hiding null/empty series\n* time regions", + "mode": "markdown" + } + } }, "fieldConfig": { "defaults": {}, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests.v42.v2alpha1.json index 77874ee6291..fe4271d08e7 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests.v42.v2alpha1.json @@ -62,6 +62,12 @@ "spec": { "pluginVersion": "7.4.0-pre", "options": { + "__angularMigration": { + "autoMigrateFrom": "gauge", + "originalOptions": { + "nullPointMode": "null" + } + }, "baseColor": "#299c46", "reduceOptions": { "calcs": [ @@ -151,6 +157,12 @@ "spec": { "pluginVersion": "7.4.0-pre", "options": { + "__angularMigration": { + "autoMigrateFrom": "gauge", + "originalOptions": { + "nullPointMode": "null" + } + }, "baseColor": "#299c46", "reduceOptions": { "calcs": [ @@ -241,6 +253,12 @@ "spec": { "pluginVersion": "7.4.0-pre", "options": { + "__angularMigration": { + "autoMigrateFrom": "gauge", + "originalOptions": { + "nullPointMode": "null" + } + }, "baseColor": "#299c46", "reduceOptions": { "calcs": [ @@ -320,6 +338,12 @@ "spec": { "pluginVersion": "7.4.0-pre", "options": { + "__angularMigration": { + "autoMigrateFrom": "gauge", + "originalOptions": { + "nullPointMode": "null" + } + }, "baseColor": "#299c46", "reduceOptions": { "calcs": [ @@ -401,6 +425,12 @@ "spec": { "pluginVersion": "7.4.0-pre", "options": { + "__angularMigration": { + "autoMigrateFrom": "gauge", + "originalOptions": { + "nullPointMode": "null" + } + }, "baseColor": "#299c46", "reduceOptions": { "calcs": [ @@ -480,6 +510,12 @@ "spec": { "pluginVersion": "7.4.0-pre", "options": { + "__angularMigration": { + "autoMigrateFrom": "gauge", + "originalOptions": { + "nullPointMode": "null" + } + }, "baseColor": "#299c46", "reduceOptions": { "calcs": [ @@ -559,6 +595,12 @@ "spec": { "pluginVersion": "7.4.0-pre", "options": { + "__angularMigration": { + "autoMigrateFrom": "gauge", + "originalOptions": { + "nullPointMode": "null" + } + }, "baseColor": "#299c46", "reduceOptions": { "calcs": [ @@ -639,6 +681,12 @@ "spec": { "pluginVersion": "7.4.0-pre", "options": { + "__angularMigration": { + "autoMigrateFrom": "gauge", + "originalOptions": { + "nullPointMode": "null" + } + }, "baseColor": "#299c46", "reduceOptions": { "calcs": [ @@ -730,6 +778,12 @@ "spec": { "pluginVersion": "7.4.0-pre", "options": { + "__angularMigration": { + "autoMigrateFrom": "gauge", + "originalOptions": { + "nullPointMode": "null" + } + }, "baseColor": "#299c46", "reduceOptions": { "calcs": [ @@ -927,6 +981,12 @@ "spec": { "pluginVersion": "7.4.0-pre", "options": { + "__angularMigration": { + "autoMigrateFrom": "gauge", + "originalOptions": { + "nullPointMode": "null" + } + }, "baseColor": "#299c46", "reduceOptions": { "calcs": [ @@ -1006,6 +1066,12 @@ "spec": { "pluginVersion": "7.4.0-pre", "options": { + "__angularMigration": { + "autoMigrateFrom": "gauge", + "originalOptions": { + "nullPointMode": "null" + } + }, "baseColor": "#299c46", "reduceOptions": { "calcs": [ @@ -1085,6 +1151,12 @@ "spec": { "pluginVersion": "7.4.0-pre", "options": { + "__angularMigration": { + "autoMigrateFrom": "gauge", + "originalOptions": { + "nullPointMode": "null" + } + }, "baseColor": "#299c46", "reduceOptions": { "calcs": [ diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests.v42.v2beta1.json index 663abfbf74c..261cd5228db 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests.v42.v2beta1.json @@ -67,6 +67,12 @@ "version": "7.4.0-pre", "spec": { "options": { + "__angularMigration": { + "autoMigrateFrom": "gauge", + "originalOptions": { + "nullPointMode": "null" + } + }, "baseColor": "#299c46", "reduceOptions": { "calcs": [ @@ -159,6 +165,12 @@ "version": "7.4.0-pre", "spec": { "options": { + "__angularMigration": { + "autoMigrateFrom": "gauge", + "originalOptions": { + "nullPointMode": "null" + } + }, "baseColor": "#299c46", "reduceOptions": { "calcs": [ @@ -252,6 +264,12 @@ "version": "7.4.0-pre", "spec": { "options": { + "__angularMigration": { + "autoMigrateFrom": "gauge", + "originalOptions": { + "nullPointMode": "null" + } + }, "baseColor": "#299c46", "reduceOptions": { "calcs": [ @@ -334,6 +352,12 @@ "version": "7.4.0-pre", "spec": { "options": { + "__angularMigration": { + "autoMigrateFrom": "gauge", + "originalOptions": { + "nullPointMode": "null" + } + }, "baseColor": "#299c46", "reduceOptions": { "calcs": [ @@ -418,6 +442,12 @@ "version": "7.4.0-pre", "spec": { "options": { + "__angularMigration": { + "autoMigrateFrom": "gauge", + "originalOptions": { + "nullPointMode": "null" + } + }, "baseColor": "#299c46", "reduceOptions": { "calcs": [ @@ -500,6 +530,12 @@ "version": "7.4.0-pre", "spec": { "options": { + "__angularMigration": { + "autoMigrateFrom": "gauge", + "originalOptions": { + "nullPointMode": "null" + } + }, "baseColor": "#299c46", "reduceOptions": { "calcs": [ @@ -582,6 +618,12 @@ "version": "7.4.0-pre", "spec": { "options": { + "__angularMigration": { + "autoMigrateFrom": "gauge", + "originalOptions": { + "nullPointMode": "null" + } + }, "baseColor": "#299c46", "reduceOptions": { "calcs": [ @@ -665,6 +707,12 @@ "version": "7.4.0-pre", "spec": { "options": { + "__angularMigration": { + "autoMigrateFrom": "gauge", + "originalOptions": { + "nullPointMode": "null" + } + }, "baseColor": "#299c46", "reduceOptions": { "calcs": [ @@ -759,6 +807,12 @@ "version": "7.4.0-pre", "spec": { "options": { + "__angularMigration": { + "autoMigrateFrom": "gauge", + "originalOptions": { + "nullPointMode": "null" + } + }, "baseColor": "#299c46", "reduceOptions": { "calcs": [ @@ -961,6 +1015,12 @@ "version": "7.4.0-pre", "spec": { "options": { + "__angularMigration": { + "autoMigrateFrom": "gauge", + "originalOptions": { + "nullPointMode": "null" + } + }, "baseColor": "#299c46", "reduceOptions": { "calcs": [ @@ -1043,6 +1103,12 @@ "version": "7.4.0-pre", "spec": { "options": { + "__angularMigration": { + "autoMigrateFrom": "gauge", + "originalOptions": { + "nullPointMode": "null" + } + }, "baseColor": "#299c46", "reduceOptions": { "calcs": [ @@ -1125,6 +1191,12 @@ "version": "7.4.0-pre", "spec": { "options": { + "__angularMigration": { + "autoMigrateFrom": "gauge", + "originalOptions": { + "nullPointMode": "null" + } + }, "baseColor": "#299c46", "reduceOptions": { "calcs": [ diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-graph/v0alpha1.graph_tests.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-graph/v0alpha1.graph_tests.v42.v2alpha1.json index f93a54ad34d..fa1ae3a63d8 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-graph/v0alpha1.graph_tests.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-graph/v0alpha1.graph_tests.v42.v2alpha1.json @@ -412,7 +412,17 @@ "kind": "text", "spec": { "pluginVersion": "", - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "text", + "originalOptions": { + "content": "Should be a long line connecting the null region in the `connected` mode, and in zero it should just be a line with zero value at the null points. ", + "editable": true, + "error": false, + "mode": "markdown" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] @@ -456,7 +466,17 @@ "kind": "text", "spec": { "pluginVersion": "", - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "text", + "originalOptions": { + "content": "Stacking values on top of nulls, should treat the null values as zero. ", + "editable": true, + "error": false, + "mode": "markdown" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] @@ -500,7 +520,17 @@ "kind": "text", "spec": { "pluginVersion": "", - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "text", + "originalOptions": { + "content": "Stacking when all values are null should leave a gap in the graph", + "editable": true, + "error": false, + "mode": "markdown" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] @@ -1681,7 +1711,17 @@ "kind": "text", "spec": { "pluginVersion": "", - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "text", + "originalOptions": { + "content": "Left is showing null between values for a normal line graph and staircase graph. Orphaned data points should be rendered as points", + "editable": true, + "error": false, + "mode": "markdown" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] @@ -2061,7 +2101,17 @@ "kind": "text", "spec": { "pluginVersion": "", - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "text", + "originalOptions": { + "content": "Just verify that the tooltip time has millisecond resolution ", + "editable": true, + "error": false, + "mode": "markdown" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] @@ -2105,7 +2155,17 @@ "kind": "text", "spec": { "pluginVersion": "", - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "text", + "originalOptions": { + "content": "Verify that axis labels look ok", + "editable": true, + "error": false, + "mode": "markdown" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-graph/v0alpha1.graph_tests.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-graph/v0alpha1.graph_tests.v42.v2beta1.json index d611243ea1d..5a580bbaa59 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-graph/v0alpha1.graph_tests.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-graph/v0alpha1.graph_tests.v42.v2beta1.json @@ -429,7 +429,17 @@ "group": "text", "version": "", "spec": { - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "text", + "originalOptions": { + "content": "Should be a long line connecting the null region in the `connected` mode, and in zero it should just be a line with zero value at the null points. ", + "editable": true, + "error": false, + "mode": "markdown" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] @@ -475,7 +485,17 @@ "group": "text", "version": "", "spec": { - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "text", + "originalOptions": { + "content": "Stacking values on top of nulls, should treat the null values as zero. ", + "editable": true, + "error": false, + "mode": "markdown" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] @@ -521,7 +541,17 @@ "group": "text", "version": "", "spec": { - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "text", + "originalOptions": { + "content": "Stacking when all values are null should leave a gap in the graph", + "editable": true, + "error": false, + "mode": "markdown" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] @@ -1779,7 +1809,17 @@ "group": "text", "version": "", "spec": { - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "text", + "originalOptions": { + "content": "Left is showing null between values for a normal line graph and staircase graph. Orphaned data points should be rendered as points", + "editable": true, + "error": false, + "mode": "markdown" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] @@ -2172,7 +2212,17 @@ "group": "text", "version": "", "spec": { - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "text", + "originalOptions": { + "content": "Just verify that the tooltip time has millisecond resolution ", + "editable": true, + "error": false, + "mode": "markdown" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] @@ -2218,7 +2268,17 @@ "group": "text", "version": "", "spec": { - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "text", + "originalOptions": { + "content": "Verify that axis labels look ok", + "editable": true, + "error": false, + "mode": "markdown" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-heatmap/v0alpha1.heatmap-legacy.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-heatmap/v0alpha1.heatmap-legacy.v42.v2alpha1.json index 4873fe5fc74..8225aec6834 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-heatmap/v0alpha1.heatmap-legacy.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-heatmap/v0alpha1.heatmap-legacy.v42.v2alpha1.json @@ -74,7 +74,44 @@ "kind": "heatmap", "spec": { "pluginVersion": "", - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "heatmap", + "originalOptions": { + "cards": {}, + "color": { + "cardColor": "#b4ff00", + "colorScale": "sqrt", + "colorScheme": "interpolateViridis", + "exponent": 0.5, + "mode": "spectrum" + }, + "dataFormat": "timeseries", + "heatmap": {}, + "hideZeroBuckets": false, + "highlightCards": true, + "legend": { + "show": true + }, + "reverseYBuckets": false, + "tooltip": { + "show": true, + "showHistogram": true + }, + "tooltipDecimals": 4, + "xAxis": { + "show": true + }, + "yAxis": { + "decimals": 2, + "format": "areaM2", + "logBase": 1, + "show": true + }, + "yBucketBound": "auto" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] @@ -116,7 +153,46 @@ "kind": "heatmap", "spec": { "pluginVersion": "", - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "heatmap", + "originalOptions": { + "cards": { + "cardRound": 50 + }, + "color": { + "cardColor": "#1F60C4", + "colorScale": "sqrt", + "colorScheme": "interpolateOranges", + "exponent": 0.5, + "mode": "opacity" + }, + "dataFormat": "tsbuckets", + "heatmap": {}, + "hideZeroBuckets": false, + "highlightCards": true, + "legend": { + "show": true + }, + "reverseYBuckets": false, + "tooltip": { + "show": true, + "showHistogram": false + }, + "xAxis": { + "show": true + }, + "yAxis": { + "decimals": 1, + "format": "kwatt", + "logBase": 1, + "show": true, + "width": "100" + }, + "yBucketBound": "auto" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] @@ -158,7 +234,44 @@ "kind": "heatmap", "spec": { "pluginVersion": "", - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "heatmap", + "originalOptions": { + "cards": {}, + "color": { + "cardColor": "#1F60C4", + "colorScale": "sqrt", + "colorScheme": "interpolateOranges", + "exponent": 0.5, + "mode": "opacity" + }, + "dataFormat": "tsbuckets", + "heatmap": {}, + "hideZeroBuckets": false, + "highlightCards": true, + "legend": { + "show": true + }, + "reverseYBuckets": true, + "tooltip": { + "show": true, + "showHistogram": false + }, + "xAxis": { + "show": true + }, + "yAxis": { + "decimals": 1, + "format": "kwatt", + "logBase": 1, + "show": true, + "width": "100" + }, + "yBucketBound": "auto" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] @@ -204,7 +317,46 @@ "kind": "heatmap", "spec": { "pluginVersion": "", - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "heatmap", + "originalOptions": { + "cards": {}, + "color": { + "cardColor": "#b4ff00", + "colorScale": "sqrt", + "colorScheme": "interpolateViridis", + "exponent": 0.5, + "mode": "spectrum" + }, + "dataFormat": "timeseries", + "heatmap": {}, + "hideZeroBuckets": false, + "highlightCards": true, + "legend": { + "show": true + }, + "reverseYBuckets": false, + "tooltip": { + "show": true, + "showHistogram": true + }, + "tooltipDecimals": 4, + "xAxis": { + "show": true + }, + "yAxis": { + "decimals": 2, + "format": "areaM2", + "logBase": 1, + "max": "50", + "min": "20", + "show": true + }, + "yBucketBound": "auto" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] @@ -246,7 +398,44 @@ "kind": "heatmap", "spec": { "pluginVersion": "", - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "heatmap", + "originalOptions": { + "cards": {}, + "color": { + "cardColor": "#b4ff00", + "colorScale": "sqrt", + "colorScheme": "interpolateBuGn", + "exponent": 0.5, + "mode": "spectrum" + }, + "dataFormat": "timeseries", + "heatmap": {}, + "hideZeroBuckets": true, + "highlightCards": true, + "legend": { + "show": true + }, + "reverseYBuckets": false, + "tooltip": { + "show": true, + "showHistogram": true + }, + "xAxis": { + "show": true + }, + "xBucketNumber": 10, + "yAxis": { + "format": "short", + "logBase": 2, + "show": true, + "splitFactor": 2 + }, + "yBucketBound": "auto" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] @@ -288,7 +477,44 @@ "kind": "heatmap", "spec": { "pluginVersion": "", - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "heatmap", + "originalOptions": { + "cards": {}, + "color": { + "cardColor": "#b4ff00", + "colorScale": "sqrt", + "colorScheme": "interpolateBuGn", + "exponent": 0.5, + "mode": "spectrum" + }, + "dataFormat": "timeseries", + "heatmap": {}, + "hideZeroBuckets": true, + "highlightCards": true, + "legend": { + "show": true + }, + "reverseYBuckets": false, + "tooltip": { + "show": true, + "showHistogram": true + }, + "xAxis": { + "show": true + }, + "xBucketNumber": 10, + "yAxis": { + "format": "short", + "logBase": 10, + "show": true, + "splitFactor": 5 + }, + "yBucketBound": "auto" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-heatmap/v0alpha1.heatmap-legacy.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-heatmap/v0alpha1.heatmap-legacy.v42.v2beta1.json index 2e11457ec4d..d7ec2abb5e8 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-heatmap/v0alpha1.heatmap-legacy.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-heatmap/v0alpha1.heatmap-legacy.v42.v2beta1.json @@ -78,7 +78,44 @@ "group": "heatmap", "version": "", "spec": { - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "heatmap", + "originalOptions": { + "cards": {}, + "color": { + "cardColor": "#b4ff00", + "colorScale": "sqrt", + "colorScheme": "interpolateViridis", + "exponent": 0.5, + "mode": "spectrum" + }, + "dataFormat": "timeseries", + "heatmap": {}, + "hideZeroBuckets": false, + "highlightCards": true, + "legend": { + "show": true + }, + "reverseYBuckets": false, + "tooltip": { + "show": true, + "showHistogram": true + }, + "tooltipDecimals": 4, + "xAxis": { + "show": true + }, + "yAxis": { + "decimals": 2, + "format": "areaM2", + "logBase": 1, + "show": true + }, + "yBucketBound": "auto" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] @@ -123,7 +160,46 @@ "group": "heatmap", "version": "", "spec": { - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "heatmap", + "originalOptions": { + "cards": { + "cardRound": 50 + }, + "color": { + "cardColor": "#1F60C4", + "colorScale": "sqrt", + "colorScheme": "interpolateOranges", + "exponent": 0.5, + "mode": "opacity" + }, + "dataFormat": "tsbuckets", + "heatmap": {}, + "hideZeroBuckets": false, + "highlightCards": true, + "legend": { + "show": true + }, + "reverseYBuckets": false, + "tooltip": { + "show": true, + "showHistogram": false + }, + "xAxis": { + "show": true + }, + "yAxis": { + "decimals": 1, + "format": "kwatt", + "logBase": 1, + "show": true, + "width": "100" + }, + "yBucketBound": "auto" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] @@ -168,7 +244,44 @@ "group": "heatmap", "version": "", "spec": { - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "heatmap", + "originalOptions": { + "cards": {}, + "color": { + "cardColor": "#1F60C4", + "colorScale": "sqrt", + "colorScheme": "interpolateOranges", + "exponent": 0.5, + "mode": "opacity" + }, + "dataFormat": "tsbuckets", + "heatmap": {}, + "hideZeroBuckets": false, + "highlightCards": true, + "legend": { + "show": true + }, + "reverseYBuckets": true, + "tooltip": { + "show": true, + "showHistogram": false + }, + "xAxis": { + "show": true + }, + "yAxis": { + "decimals": 1, + "format": "kwatt", + "logBase": 1, + "show": true, + "width": "100" + }, + "yBucketBound": "auto" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] @@ -216,7 +329,46 @@ "group": "heatmap", "version": "", "spec": { - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "heatmap", + "originalOptions": { + "cards": {}, + "color": { + "cardColor": "#b4ff00", + "colorScale": "sqrt", + "colorScheme": "interpolateViridis", + "exponent": 0.5, + "mode": "spectrum" + }, + "dataFormat": "timeseries", + "heatmap": {}, + "hideZeroBuckets": false, + "highlightCards": true, + "legend": { + "show": true + }, + "reverseYBuckets": false, + "tooltip": { + "show": true, + "showHistogram": true + }, + "tooltipDecimals": 4, + "xAxis": { + "show": true + }, + "yAxis": { + "decimals": 2, + "format": "areaM2", + "logBase": 1, + "max": "50", + "min": "20", + "show": true + }, + "yBucketBound": "auto" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] @@ -261,7 +413,44 @@ "group": "heatmap", "version": "", "spec": { - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "heatmap", + "originalOptions": { + "cards": {}, + "color": { + "cardColor": "#b4ff00", + "colorScale": "sqrt", + "colorScheme": "interpolateBuGn", + "exponent": 0.5, + "mode": "spectrum" + }, + "dataFormat": "timeseries", + "heatmap": {}, + "hideZeroBuckets": true, + "highlightCards": true, + "legend": { + "show": true + }, + "reverseYBuckets": false, + "tooltip": { + "show": true, + "showHistogram": true + }, + "xAxis": { + "show": true + }, + "xBucketNumber": 10, + "yAxis": { + "format": "short", + "logBase": 2, + "show": true, + "splitFactor": 2 + }, + "yBucketBound": "auto" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] @@ -306,7 +495,44 @@ "group": "heatmap", "version": "", "spec": { - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "heatmap", + "originalOptions": { + "cards": {}, + "color": { + "cardColor": "#b4ff00", + "colorScale": "sqrt", + "colorScheme": "interpolateBuGn", + "exponent": 0.5, + "mode": "spectrum" + }, + "dataFormat": "timeseries", + "heatmap": {}, + "hideZeroBuckets": true, + "highlightCards": true, + "legend": { + "show": true + }, + "reverseYBuckets": false, + "tooltip": { + "show": true, + "showHistogram": true + }, + "xAxis": { + "show": true + }, + "xBucketNumber": 10, + "yAxis": { + "format": "short", + "logBase": 10, + "show": true, + "splitFactor": 5 + }, + "yBucketBound": "auto" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-polystat/v0alpha1.polystat_test.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-polystat/v0alpha1.polystat_test.v42.v2alpha1.json index 68c7c2b6529..636612934c4 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-polystat/v0alpha1.polystat_test.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-polystat/v0alpha1.polystat_test.v42.v2alpha1.json @@ -119,7 +119,1073 @@ "kind": "grafana-polystat-panel", "spec": { "pluginVersion": "", - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "grafana-polystat-panel", + "originalOptions": { + "animationModes": [ + { + "text": "Show All", + "value": "all" + }, + { + "text": "Show Triggered", + "value": "triggered" + } + ], + "colors": [ + "#299c46", + "rgba(237, 129, 40, 0.89)", + "#d44a3a" + ], + "d3DivId": "d3_svg_2", + "decimals": 2, + "displayModes": [ + { + "text": "Show All", + "value": "all" + }, + { + "text": "Show Triggered", + "value": "triggered" + } + ], + "fontSizes": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 22, + 24, + 26, + 28, + 30, + 32, + 34, + 36, + 38, + 40, + 42, + 44, + 46, + 48, + 50, + 52, + 54, + 56, + 58, + 60, + 62, + 64, + 66, + 68, + 70 + ], + "fontTypes": [ + "Open Sans", + "Arial", + "Avant Garde", + "Bookman", + "Consolas", + "Courier", + "Courier New", + "Futura", + "Garamond", + "Helvetica", + "Palatino", + "Times", + "Times New Roman", + "Verdana" + ], + "format": "none", + "notcolors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "operatorName": "avg", + "operatorOptions": [ + { + "text": "Average", + "value": "avg" + }, + { + "text": "Count", + "value": "count" + }, + { + "text": "Current", + "value": "current" + }, + { + "text": "Delta", + "value": "delta" + }, + { + "text": "Difference", + "value": "diff" + }, + { + "text": "First", + "value": "first" + }, + { + "text": "Log Min", + "value": "logmin" + }, + { + "text": "Max", + "value": "max" + }, + { + "text": "Min", + "value": "min" + }, + { + "text": "Name", + "value": "name" + }, + { + "text": "Time of Last Point", + "value": "last_time" + }, + { + "text": "Time Step", + "value": "time_step" + }, + { + "text": "Total", + "value": "total" + } + ], + "polystat": { + "animationSpeed": 2500, + "columnAutoSize": true, + "columns": 1, + "defaultClickThrough": "", + "defaultClickThroughSanitize": true, + "displayLimit": 100, + "fontAutoScale": true, + "fontSize": 12, + "globalDisplayMode": "all", + "globalOperatorName": "avg", + "gradientEnabled": true, + "hexagonSortByDirection": "asc", + "hexagonSortByField": "name", + "maxMetrics": 0, + "polygonBorderColor": "black", + "polygonBorderSize": 2, + "radius": "", + "radiusAutoSize": true, + "rowAutoSize": true, + "rows": 1, + "shape": "hexagon_pointed_top", + "tooltipDisplayMode": "all", + "tooltipDisplayTextTriggeredEmpty": "OK", + "tooltipFontSize": 12, + "tooltipFontType": "Open Sans", + "tooltipPrimarySortDirection": "desc", + "tooltipPrimarySortField": "thresholdLevel", + "tooltipSecondarySortDirection": "desc", + "tooltipSecondarySortField": "value", + "tooltipTimestampEnabled": true + }, + "savedComposites": [], + "savedOverrides": [], + "shapes": [ + { + "text": "Hexagon Pointed Top", + "value": "hexagon_pointed_top" + }, + { + "text": "Hexagon Flat Top", + "value": "hexagon_flat_top" + }, + { + "text": "Circle", + "value": "circle" + }, + { + "text": "Cross", + "value": "cross" + }, + { + "text": "Diamond", + "value": "diamond" + }, + { + "text": "Square", + "value": "square" + }, + { + "text": "Star", + "value": "star" + }, + { + "text": "Triangle", + "value": "triangle" + }, + { + "text": "Wye", + "value": "wye" + } + ], + "sortDirections": [ + { + "text": "Ascending", + "value": "asc" + }, + { + "text": "Descending", + "value": "desc" + } + ], + "sortFields": [ + { + "text": "Name", + "value": "name" + }, + { + "text": "Threshold Level", + "value": "thresholdLevel" + }, + { + "text": "Value", + "value": "value" + } + ], + "svgContainer": {}, + "thresholdStates": [ + { + "text": "ok", + "value": 0 + }, + { + "text": "warning", + "value": 1 + }, + { + "text": "critical", + "value": 2 + }, + { + "text": "custom", + "value": 3 + } + ], + "unitFormats": [ + { + "submenu": [ + { + "text": "none", + "value": "none" + }, + { + "text": "short", + "value": "short" + }, + { + "text": "percent (0-100)", + "value": "percent" + }, + { + "text": "percent (0.0-1.0)", + "value": "percentunit" + }, + { + "text": "Humidity (%H)", + "value": "humidity" + }, + { + "text": "decibel", + "value": "dB" + }, + { + "text": "hexadecimal (0x)", + "value": "hex0x" + }, + { + "text": "hexadecimal", + "value": "hex" + }, + { + "text": "scientific notation", + "value": "sci" + }, + { + "text": "locale format", + "value": "locale" + } + ], + "text": "none" + }, + { + "submenu": [ + { + "text": "Dollars ($)", + "value": "currencyUSD" + }, + { + "text": "Pounds (£)", + "value": "currencyGBP" + }, + { + "text": "Euro (€)", + "value": "currencyEUR" + }, + { + "text": "Yen (¥)", + "value": "currencyJPY" + }, + { + "text": "Rubles (₽)", + "value": "currencyRUB" + }, + { + "text": "Hryvnias (₴)", + "value": "currencyUAH" + }, + { + "text": "Real (R$)", + "value": "currencyBRL" + }, + { + "text": "Danish Krone (kr)", + "value": "currencyDKK" + }, + { + "text": "Icelandic Króna (kr)", + "value": "currencyISK" + }, + { + "text": "Norwegian Krone (kr)", + "value": "currencyNOK" + }, + { + "text": "Swedish Krona (kr)", + "value": "currencySEK" + }, + { + "text": "Czech koruna (czk)", + "value": "currencyCZK" + }, + { + "text": "Swiss franc (CHF)", + "value": "currencyCHF" + }, + { + "text": "Polish Złoty (PLN)", + "value": "currencyPLN" + }, + { + "text": "Bitcoin (฿)", + "value": "currencyBTC" + }, + { + "text": "Vietnamese Dong (VND)", + "value": "currencyVND" + }, + { + "text": "Malaysian Ringgit (RM)", + "value": "currencyMYR" + }, + { + "text": "Bulgarian Lev (BGN)", + "value": "currencyBGN" + } + ], + "text": "currency" + }, + { + "submenu": [ + { + "text": "Hertz (1/s)", + "value": "hertz" + }, + { + "text": "nanoseconds (ns)", + "value": "ns" + }, + { + "text": "microseconds (µs)", + "value": "µs" + }, + { + "text": "milliseconds (ms)", + "value": "ms" + }, + { + "text": "seconds (s)", + "value": "s" + }, + { + "text": "minutes (m)", + "value": "m" + }, + { + "text": "hours (h)", + "value": "h" + }, + { + "text": "days (d)", + "value": "d" + }, + { + "text": "duration (ms)", + "value": "dtdurationms" + }, + { + "text": "duration (s)", + "value": "dtdurations" + }, + { + "text": "duration (hh:mm:ss)", + "value": "dthms" + }, + { + "text": "Timeticks (s/100)", + "value": "timeticks" + } + ], + "text": "time" + }, + { + "submenu": [ + { + "text": "YYYY-MM-DD HH:mm:ss", + "value": "dateTimeAsIso" + }, + { + "text": "DD/MM/YYYY h:mm:ss a", + "value": "dateTimeAsUS" + }, + { + "text": "From Now", + "value": "dateTimeFromNow" + } + ], + "text": "date \u0026 time" + }, + { + "submenu": [ + { + "text": "bits", + "value": "bits" + }, + { + "text": "bytes", + "value": "bytes" + }, + { + "text": "kibibytes", + "value": "kbytes" + }, + { + "text": "mebibytes", + "value": "mbytes" + }, + { + "text": "gibibytes", + "value": "gbytes" + } + ], + "text": "data (IEC)" + }, + { + "submenu": [ + { + "text": "bits", + "value": "decbits" + }, + { + "text": "bytes", + "value": "decbytes" + }, + { + "text": "kilobytes", + "value": "deckbytes" + }, + { + "text": "megabytes", + "value": "decmbytes" + }, + { + "text": "gigabytes", + "value": "decgbytes" + } + ], + "text": "data (Metric)" + }, + { + "submenu": [ + { + "text": "packets/sec", + "value": "pps" + }, + { + "text": "bits/sec", + "value": "bps" + }, + { + "text": "bytes/sec", + "value": "Bps" + }, + { + "text": "kilobits/sec", + "value": "Kbits" + }, + { + "text": "kilobytes/sec", + "value": "KBs" + }, + { + "text": "megabits/sec", + "value": "Mbits" + }, + { + "text": "megabytes/sec", + "value": "MBs" + }, + { + "text": "gigabytes/sec", + "value": "GBs" + }, + { + "text": "gigabits/sec", + "value": "Gbits" + } + ], + "text": "data rate" + }, + { + "submenu": [ + { + "text": "hashes/sec", + "value": "Hs" + }, + { + "text": "kilohashes/sec", + "value": "KHs" + }, + { + "text": "megahashes/sec", + "value": "MHs" + }, + { + "text": "gigahashes/sec", + "value": "GHs" + }, + { + "text": "terahashes/sec", + "value": "THs" + }, + { + "text": "petahashes/sec", + "value": "PHs" + }, + { + "text": "exahashes/sec", + "value": "EHs" + } + ], + "text": "hash rate" + }, + { + "submenu": [ + { + "text": "ops/sec (ops)", + "value": "ops" + }, + { + "text": "requests/sec (rps)", + "value": "reqps" + }, + { + "text": "reads/sec (rps)", + "value": "rps" + }, + { + "text": "writes/sec (wps)", + "value": "wps" + }, + { + "text": "I/O ops/sec (iops)", + "value": "iops" + }, + { + "text": "ops/min (opm)", + "value": "opm" + }, + { + "text": "requests/min (rpm)", + "value": "reqpm" + }, + { + "text": "reads/min (rpm)", + "value": "rpm" + }, + { + "text": "writes/min (wpm)", + "value": "wpm" + } + ], + "text": "throughput" + }, + { + "submenu": [ + { + "text": "millimetre (mm)", + "value": "lengthmm" + }, + { + "text": "meter (m)", + "value": "lengthm" + }, + { + "text": "inch (in)", + "value": "lengthin" + }, + { + "text": "feet (ft)", + "value": "lengthft" + }, + { + "text": "kilometer (km)", + "value": "lengthkm" + }, + { + "text": "mile (mi)", + "value": "lengthmi" + } + ], + "text": "length" + }, + { + "submenu": [ + { + "text": "Square Meters (m²)", + "value": "areaM2" + }, + { + "text": "Square Feet (ft²)", + "value": "areaF2" + }, + { + "text": "Square Miles (mi²)", + "value": "areaMI2" + } + ], + "text": "area" + }, + { + "submenu": [ + { + "text": "milligram (mg)", + "value": "massmg" + }, + { + "text": "gram (g)", + "value": "massg" + }, + { + "text": "pound (lb)", + "value": "masslb" + }, + { + "text": "kilogram (kg)", + "value": "masskg" + }, + { + "text": "metric ton (t)", + "value": "masst" + } + ], + "text": "mass" + }, + { + "submenu": [ + { + "text": "metres/second (m/s)", + "value": "velocityms" + }, + { + "text": "kilometers/hour (km/h)", + "value": "velocitykmh" + }, + { + "text": "miles/hour (mph)", + "value": "velocitymph" + }, + { + "text": "knot (kn)", + "value": "velocityknot" + } + ], + "text": "velocity" + }, + { + "submenu": [ + { + "text": "millilitre (mL)", + "value": "mlitre" + }, + { + "text": "litre (L)", + "value": "litre" + }, + { + "text": "cubic metre", + "value": "m3" + }, + { + "text": "Normal cubic metre", + "value": "Nm3" + }, + { + "text": "cubic decimetre", + "value": "dm3" + }, + { + "text": "gallons", + "value": "gallons" + } + ], + "text": "volume" + }, + { + "submenu": [ + { + "text": "Watt (W)", + "value": "watt" + }, + { + "text": "Kilowatt (kW)", + "value": "kwatt" + }, + { + "text": "Milliwatt (mW)", + "value": "mwatt" + }, + { + "text": "Watt per square metre (W/m²)", + "value": "Wm2" + }, + { + "text": "Volt-ampere (VA)", + "value": "voltamp" + }, + { + "text": "Kilovolt-ampere (kVA)", + "value": "kvoltamp" + }, + { + "text": "Volt-ampere reactive (var)", + "value": "voltampreact" + }, + { + "text": "Kilovolt-ampere reactive (kvar)", + "value": "kvoltampreact" + }, + { + "text": "Watt-hour (Wh)", + "value": "watth" + }, + { + "text": "Kilowatt-hour (kWh)", + "value": "kwatth" + }, + { + "text": "Kilowatt-min (kWm)", + "value": "kwattm" + }, + { + "text": "Joule (J)", + "value": "joule" + }, + { + "text": "Electron volt (eV)", + "value": "ev" + }, + { + "text": "Ampere (A)", + "value": "amp" + }, + { + "text": "Kiloampere (kA)", + "value": "kamp" + }, + { + "text": "Milliampere (mA)", + "value": "mamp" + }, + { + "text": "Volt (V)", + "value": "volt" + }, + { + "text": "Kilovolt (kV)", + "value": "kvolt" + }, + { + "text": "Millivolt (mV)", + "value": "mvolt" + }, + { + "text": "Decibel-milliwatt (dBm)", + "value": "dBm" + }, + { + "text": "Ohm (Ω)", + "value": "ohm" + }, + { + "text": "Lumens (Lm)", + "value": "lumens" + } + ], + "text": "energy" + }, + { + "submenu": [ + { + "text": "Celsius (°C)", + "value": "celsius" + }, + { + "text": "Fahrenheit (°F)", + "value": "fahrenheit" + }, + { + "text": "Kelvin (K)", + "value": "kelvin" + } + ], + "text": "temperature" + }, + { + "submenu": [ + { + "text": "Millibars", + "value": "pressurembar" + }, + { + "text": "Bars", + "value": "pressurebar" + }, + { + "text": "Kilobars", + "value": "pressurekbar" + }, + { + "text": "Hectopascals", + "value": "pressurehpa" + }, + { + "text": "Kilopascals", + "value": "pressurekpa" + }, + { + "text": "Inches of mercury", + "value": "pressurehg" + }, + { + "text": "PSI", + "value": "pressurepsi" + } + ], + "text": "pressure" + }, + { + "submenu": [ + { + "text": "Newton-meters (Nm)", + "value": "forceNm" + }, + { + "text": "Kilonewton-meters (kNm)", + "value": "forcekNm" + }, + { + "text": "Newtons (N)", + "value": "forceN" + }, + { + "text": "Kilonewtons (kN)", + "value": "forcekN" + } + ], + "text": "force" + }, + { + "submenu": [ + { + "text": "Gallons/min (gpm)", + "value": "flowgpm" + }, + { + "text": "Cubic meters/sec (cms)", + "value": "flowcms" + }, + { + "text": "Cubic feet/sec (cfs)", + "value": "flowcfs" + }, + { + "text": "Cubic feet/min (cfm)", + "value": "flowcfm" + }, + { + "text": "Litre/hour", + "value": "litreh" + }, + { + "text": "Litre/min (l/min)", + "value": "flowlpm" + }, + { + "text": "milliLitre/min (mL/min)", + "value": "flowmlpm" + } + ], + "text": "flow" + }, + { + "submenu": [ + { + "text": "Degrees (°)", + "value": "degree" + }, + { + "text": "Radians", + "value": "radian" + }, + { + "text": "Gradian", + "value": "grad" + } + ], + "text": "angle" + }, + { + "submenu": [ + { + "text": "Meters/sec²", + "value": "accMS2" + }, + { + "text": "Feet/sec²", + "value": "accFS2" + }, + { + "text": "G unit", + "value": "accG" + } + ], + "text": "acceleration" + }, + { + "submenu": [ + { + "text": "Becquerel (Bq)", + "value": "radbq" + }, + { + "text": "curie (Ci)", + "value": "radci" + }, + { + "text": "Gray (Gy)", + "value": "radgy" + }, + { + "text": "rad", + "value": "radrad" + }, + { + "text": "Sievert (Sv)", + "value": "radsv" + }, + { + "text": "rem", + "value": "radrem" + }, + { + "text": "Exposure (C/kg)", + "value": "radexpckg" + }, + { + "text": "roentgen (R)", + "value": "radr" + }, + { + "text": "Sievert/hour (Sv/h)", + "value": "radsvh" + } + ], + "text": "radiation" + }, + { + "submenu": [ + { + "text": "parts-per-million (ppm)", + "value": "ppm" + }, + { + "text": "parts-per-billion (ppb)", + "value": "conppb" + }, + { + "text": "nanogram per cubic metre (ng/m³)", + "value": "conngm3" + }, + { + "text": "nanogram per normal cubic metre (ng/Nm³)", + "value": "conngNm3" + }, + { + "text": "microgram per cubic metre (μg/m³)", + "value": "conμgm3" + }, + { + "text": "microgram per normal cubic metre (μg/Nm³)", + "value": "conμgNm3" + }, + { + "text": "milligram per cubic metre (mg/m³)", + "value": "conmgm3" + }, + { + "text": "milligram per normal cubic metre (mg/Nm³)", + "value": "conmgNm3" + }, + { + "text": "gram per cubic metre (g/m³)", + "value": "congm3" + }, + { + "text": "gram per normal cubic metre (g/Nm³)", + "value": "congNm3" + } + ], + "text": "concentration" + } + ] + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] @@ -228,7 +1294,1129 @@ "kind": "grafana-polystat-panel", "spec": { "pluginVersion": "", - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "grafana-polystat-panel", + "originalOptions": { + "animationModes": [ + { + "text": "Show All", + "value": "all" + }, + { + "text": "Show Triggered", + "value": "triggered" + } + ], + "colors": [ + "#299c46", + "rgba(237, 129, 40, 0.89)", + "#d44a3a" + ], + "d3DivId": "d3_svg_4", + "decimals": 2, + "displayModes": [ + { + "text": "Show All", + "value": "all" + }, + { + "text": "Show Triggered", + "value": "triggered" + } + ], + "fontSizes": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 22, + 24, + 26, + 28, + 30, + 32, + 34, + 36, + 38, + 40, + 42, + 44, + 46, + 48, + 50, + 52, + 54, + 56, + 58, + 60, + 62, + 64, + 66, + 68, + 70 + ], + "fontTypes": [ + "Open Sans", + "Arial", + "Avant Garde", + "Bookman", + "Consolas", + "Courier", + "Courier New", + "Futura", + "Garamond", + "Helvetica", + "Palatino", + "Times", + "Times New Roman", + "Verdana" + ], + "format": "none", + "notcolors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "operatorName": "avg", + "operatorOptions": [ + { + "text": "Average", + "value": "avg" + }, + { + "text": "Count", + "value": "count" + }, + { + "text": "Current", + "value": "current" + }, + { + "text": "Delta", + "value": "delta" + }, + { + "text": "Difference", + "value": "diff" + }, + { + "text": "First", + "value": "first" + }, + { + "text": "Log Min", + "value": "logmin" + }, + { + "text": "Max", + "value": "max" + }, + { + "text": "Min", + "value": "min" + }, + { + "text": "Name", + "value": "name" + }, + { + "text": "Time of Last Point", + "value": "last_time" + }, + { + "text": "Time Step", + "value": "time_step" + }, + { + "text": "Total", + "value": "total" + } + ], + "polystat": { + "animationSpeed": 2500, + "columnAutoSize": true, + "columns": "", + "defaultClickThrough": "", + "defaultClickThroughSanitize": true, + "displayLimit": 100, + "fontAutoScale": true, + "fontSize": 12, + "globalDisplayMode": "all", + "globalOperatorName": "avg", + "gradientEnabled": true, + "hexagonSortByDirection": "asc", + "hexagonSortByField": "name", + "maxMetrics": 0, + "polygonBorderColor": "black", + "polygonBorderSize": 2, + "radius": "", + "radiusAutoSize": true, + "rowAutoSize": true, + "rows": "", + "shape": "hexagon_pointed_top", + "tooltipDisplayMode": "all", + "tooltipDisplayTextTriggeredEmpty": "OK", + "tooltipFontSize": 12, + "tooltipFontType": "Open Sans", + "tooltipPrimarySortDirection": "desc", + "tooltipPrimarySortField": "thresholdLevel", + "tooltipSecondarySortDirection": "desc", + "tooltipSecondarySortField": "value", + "tooltipTimestampEnabled": true + }, + "savedComposites": [], + "savedOverrides": [], + "shapes": [ + { + "text": "Hexagon Pointed Top", + "value": "hexagon_pointed_top" + }, + { + "text": "Hexagon Flat Top", + "value": "hexagon_flat_top" + }, + { + "text": "Circle", + "value": "circle" + }, + { + "text": "Cross", + "value": "cross" + }, + { + "text": "Diamond", + "value": "diamond" + }, + { + "text": "Square", + "value": "square" + }, + { + "text": "Star", + "value": "star" + }, + { + "text": "Triangle", + "value": "triangle" + }, + { + "text": "Wye", + "value": "wye" + } + ], + "sortDirections": [ + { + "text": "Ascending", + "value": "asc" + }, + { + "text": "Descending", + "value": "desc" + } + ], + "sortFields": [ + { + "text": "Name", + "value": "name" + }, + { + "text": "Threshold Level", + "value": "thresholdLevel" + }, + { + "text": "Value", + "value": "value" + } + ], + "svgContainer": {}, + "thresholdStates": [ + { + "text": "ok", + "value": 0 + }, + { + "text": "warning", + "value": 1 + }, + { + "text": "critical", + "value": 2 + }, + { + "text": "custom", + "value": 3 + } + ], + "unitFormats": [ + { + "submenu": [ + { + "text": "none", + "value": "none" + }, + { + "text": "short", + "value": "short" + }, + { + "text": "percent (0-100)", + "value": "percent" + }, + { + "text": "percent (0.0-1.0)", + "value": "percentunit" + }, + { + "text": "Humidity (%H)", + "value": "humidity" + }, + { + "text": "decibel", + "value": "dB" + }, + { + "text": "hexadecimal (0x)", + "value": "hex0x" + }, + { + "text": "hexadecimal", + "value": "hex" + }, + { + "text": "scientific notation", + "value": "sci" + }, + { + "text": "locale format", + "value": "locale" + } + ], + "text": "none" + }, + { + "submenu": [ + { + "text": "Dollars ($)", + "value": "currencyUSD" + }, + { + "text": "Pounds (£)", + "value": "currencyGBP" + }, + { + "text": "Euro (€)", + "value": "currencyEUR" + }, + { + "text": "Yen (¥)", + "value": "currencyJPY" + }, + { + "text": "Rubles (₽)", + "value": "currencyRUB" + }, + { + "text": "Hryvnias (₴)", + "value": "currencyUAH" + }, + { + "text": "Real (R$)", + "value": "currencyBRL" + }, + { + "text": "Danish Krone (kr)", + "value": "currencyDKK" + }, + { + "text": "Icelandic Króna (kr)", + "value": "currencyISK" + }, + { + "text": "Norwegian Krone (kr)", + "value": "currencyNOK" + }, + { + "text": "Swedish Krona (kr)", + "value": "currencySEK" + }, + { + "text": "Czech koruna (czk)", + "value": "currencyCZK" + }, + { + "text": "Swiss franc (CHF)", + "value": "currencyCHF" + }, + { + "text": "Polish Złoty (PLN)", + "value": "currencyPLN" + }, + { + "text": "Bitcoin (฿)", + "value": "currencyBTC" + }, + { + "text": "Milli Bitcoin (mBTC)", + "value": "currencymBTC" + }, + { + "text": "Micro Bitcoin (μBTC)", + "value": "currencyμBTC" + }, + { + "text": "Vietnamese Dong (VND)", + "value": "currencyVND" + }, + { + "text": "Turkish Lira (₺)", + "value": "currencyTRY" + }, + { + "text": "Malaysian Ringgit (RM)", + "value": "currencyMYR" + }, + { + "text": "CFP franc (XPF)", + "value": "currencyXPF" + }, + { + "text": "Bulgarian Lev (BGN)", + "value": "currencyBGN" + } + ], + "text": "currency" + }, + { + "submenu": [ + { + "text": "Hertz (1/s)", + "value": "hertz" + }, + { + "text": "nanoseconds (ns)", + "value": "ns" + }, + { + "text": "microseconds (µs)", + "value": "µs" + }, + { + "text": "milliseconds (ms)", + "value": "ms" + }, + { + "text": "seconds (s)", + "value": "s" + }, + { + "text": "minutes (m)", + "value": "m" + }, + { + "text": "hours (h)", + "value": "h" + }, + { + "text": "days (d)", + "value": "d" + }, + { + "text": "duration (ms)", + "value": "dtdurationms" + }, + { + "text": "duration (s)", + "value": "dtdurations" + }, + { + "text": "duration (hh:mm:ss)", + "value": "dthms" + }, + { + "text": "Timeticks (s/100)", + "value": "timeticks" + } + ], + "text": "time" + }, + { + "submenu": [ + { + "text": "YYYY-MM-DD HH:mm:ss", + "value": "dateTimeAsIso" + }, + { + "text": "DD/MM/YYYY h:mm:ss a", + "value": "dateTimeAsUS" + }, + { + "text": "From Now", + "value": "dateTimeFromNow" + } + ], + "text": "date \u0026 time" + }, + { + "submenu": [ + { + "text": "bits", + "value": "bits" + }, + { + "text": "bytes", + "value": "bytes" + }, + { + "text": "kibibytes", + "value": "kbytes" + }, + { + "text": "mebibytes", + "value": "mbytes" + }, + { + "text": "gibibytes", + "value": "gbytes" + } + ], + "text": "data (IEC)" + }, + { + "submenu": [ + { + "text": "bits", + "value": "decbits" + }, + { + "text": "bytes", + "value": "decbytes" + }, + { + "text": "kilobytes", + "value": "deckbytes" + }, + { + "text": "megabytes", + "value": "decmbytes" + }, + { + "text": "gigabytes", + "value": "decgbytes" + } + ], + "text": "data (Metric)" + }, + { + "submenu": [ + { + "text": "packets/sec", + "value": "pps" + }, + { + "text": "bits/sec", + "value": "bps" + }, + { + "text": "bytes/sec", + "value": "Bps" + }, + { + "text": "kilobits/sec", + "value": "Kbits" + }, + { + "text": "kilobytes/sec", + "value": "KBs" + }, + { + "text": "megabits/sec", + "value": "Mbits" + }, + { + "text": "megabytes/sec", + "value": "MBs" + }, + { + "text": "gigabytes/sec", + "value": "GBs" + }, + { + "text": "gigabits/sec", + "value": "Gbits" + } + ], + "text": "data rate" + }, + { + "submenu": [ + { + "text": "hashes/sec", + "value": "Hs" + }, + { + "text": "kilohashes/sec", + "value": "KHs" + }, + { + "text": "megahashes/sec", + "value": "MHs" + }, + { + "text": "gigahashes/sec", + "value": "GHs" + }, + { + "text": "terahashes/sec", + "value": "THs" + }, + { + "text": "petahashes/sec", + "value": "PHs" + }, + { + "text": "exahashes/sec", + "value": "EHs" + } + ], + "text": "hash rate" + }, + { + "submenu": [ + { + "text": "counts/sec (cps)", + "value": "cps" + }, + { + "text": "ops/sec (ops)", + "value": "ops" + }, + { + "text": "requests/sec (rps)", + "value": "reqps" + }, + { + "text": "reads/sec (rps)", + "value": "rps" + }, + { + "text": "writes/sec (wps)", + "value": "wps" + }, + { + "text": "I/O ops/sec (iops)", + "value": "iops" + }, + { + "text": "events/sec (eps)", + "value": "eps" + }, + { + "text": "messages/sec (mps)", + "value": "mps" + }, + { + "text": "records/sec (rps)", + "value": "recps" + }, + { + "text": "rows/sec (rps)", + "value": "rowsps" + }, + { + "text": "counts/min (cpm)", + "value": "cpm" + }, + { + "text": "ops/min (opm)", + "value": "opm" + }, + { + "text": "requests/min (rpm)", + "value": "reqpm" + }, + { + "text": "reads/min (rpm)", + "value": "rpm" + }, + { + "text": "writes/min (wpm)", + "value": "wpm" + }, + { + "text": "events/min (epm)", + "value": "epm" + }, + { + "text": "messages/min (mpm)", + "value": "mpm" + }, + { + "text": "records/min (rpm)", + "value": "recpm" + }, + { + "text": "rows/min (rpm)", + "value": "rowspm" + } + ], + "text": "throughput" + }, + { + "submenu": [ + { + "text": "millimetre (mm)", + "value": "lengthmm" + }, + { + "text": "meter (m)", + "value": "lengthm" + }, + { + "text": "inch (in)", + "value": "lengthin" + }, + { + "text": "feet (ft)", + "value": "lengthft" + }, + { + "text": "kilometer (km)", + "value": "lengthkm" + }, + { + "text": "mile (mi)", + "value": "lengthmi" + } + ], + "text": "length" + }, + { + "submenu": [ + { + "text": "Square Meters (m²)", + "value": "areaM2" + }, + { + "text": "Square Feet (ft²)", + "value": "areaF2" + }, + { + "text": "Square Miles (mi²)", + "value": "areaMI2" + } + ], + "text": "area" + }, + { + "submenu": [ + { + "text": "milligram (mg)", + "value": "massmg" + }, + { + "text": "gram (g)", + "value": "massg" + }, + { + "text": "pound (lb)", + "value": "masslb" + }, + { + "text": "kilogram (kg)", + "value": "masskg" + }, + { + "text": "metric ton (t)", + "value": "masst" + } + ], + "text": "mass" + }, + { + "submenu": [ + { + "text": "metres/second (m/s)", + "value": "velocityms" + }, + { + "text": "kilometers/hour (km/h)", + "value": "velocitykmh" + }, + { + "text": "miles/hour (mph)", + "value": "velocitymph" + }, + { + "text": "knot (kn)", + "value": "velocityknot" + } + ], + "text": "velocity" + }, + { + "submenu": [ + { + "text": "millilitre (mL)", + "value": "mlitre" + }, + { + "text": "litre (L)", + "value": "litre" + }, + { + "text": "cubic metre", + "value": "m3" + }, + { + "text": "Normal cubic metre", + "value": "Nm3" + }, + { + "text": "cubic decimetre", + "value": "dm3" + }, + { + "text": "gallons", + "value": "gallons" + } + ], + "text": "volume" + }, + { + "submenu": [ + { + "text": "Watt (W)", + "value": "watt" + }, + { + "text": "Kilowatt (kW)", + "value": "kwatt" + }, + { + "text": "Milliwatt (mW)", + "value": "mwatt" + }, + { + "text": "Watt per square metre (W/m²)", + "value": "Wm2" + }, + { + "text": "Volt-Ampere (VA)", + "value": "voltamp" + }, + { + "text": "Kilovolt-Ampere (kVA)", + "value": "kvoltamp" + }, + { + "text": "Volt-Ampere reactive (VAr)", + "value": "voltampreact" + }, + { + "text": "Kilovolt-Ampere reactive (kVAr)", + "value": "kvoltampreact" + }, + { + "text": "Watt-hour (Wh)", + "value": "watth" + }, + { + "text": "Kilowatt-hour (kWh)", + "value": "kwatth" + }, + { + "text": "Kilowatt-min (kWm)", + "value": "kwattm" + }, + { + "text": "Joule (J)", + "value": "joule" + }, + { + "text": "Electron volt (eV)", + "value": "ev" + }, + { + "text": "Ampere (A)", + "value": "amp" + }, + { + "text": "Kiloampere (kA)", + "value": "kamp" + }, + { + "text": "Milliampere (mA)", + "value": "mamp" + }, + { + "text": "Volt (V)", + "value": "volt" + }, + { + "text": "Kilovolt (kV)", + "value": "kvolt" + }, + { + "text": "Millivolt (mV)", + "value": "mvolt" + }, + { + "text": "Decibel-milliwatt (dBm)", + "value": "dBm" + }, + { + "text": "Ohm (Ω)", + "value": "ohm" + }, + { + "text": "Lumens (Lm)", + "value": "lumens" + } + ], + "text": "energy" + }, + { + "submenu": [ + { + "text": "Celsius (°C)", + "value": "celsius" + }, + { + "text": "Fahrenheit (°F)", + "value": "fahrenheit" + }, + { + "text": "Kelvin (K)", + "value": "kelvin" + } + ], + "text": "temperature" + }, + { + "submenu": [ + { + "text": "Millibars", + "value": "pressurembar" + }, + { + "text": "Bars", + "value": "pressurebar" + }, + { + "text": "Kilobars", + "value": "pressurekbar" + }, + { + "text": "Hectopascals", + "value": "pressurehpa" + }, + { + "text": "Kilopascals", + "value": "pressurekpa" + }, + { + "text": "Inches of mercury", + "value": "pressurehg" + }, + { + "text": "PSI", + "value": "pressurepsi" + } + ], + "text": "pressure" + }, + { + "submenu": [ + { + "text": "Newton-meters (Nm)", + "value": "forceNm" + }, + { + "text": "Kilonewton-meters (kNm)", + "value": "forcekNm" + }, + { + "text": "Newtons (N)", + "value": "forceN" + }, + { + "text": "Kilonewtons (kN)", + "value": "forcekN" + } + ], + "text": "force" + }, + { + "submenu": [ + { + "text": "Gallons/min (gpm)", + "value": "flowgpm" + }, + { + "text": "Cubic meters/sec (cms)", + "value": "flowcms" + }, + { + "text": "Cubic feet/sec (cfs)", + "value": "flowcfs" + }, + { + "text": "Cubic feet/min (cfm)", + "value": "flowcfm" + }, + { + "text": "Litre/hour", + "value": "litreh" + }, + { + "text": "Litre/min (l/min)", + "value": "flowlpm" + }, + { + "text": "milliLitre/min (mL/min)", + "value": "flowmlpm" + } + ], + "text": "flow" + }, + { + "submenu": [ + { + "text": "Degrees (°)", + "value": "degree" + }, + { + "text": "Radians", + "value": "radian" + }, + { + "text": "Gradian", + "value": "grad" + } + ], + "text": "angle" + }, + { + "submenu": [ + { + "text": "Meters/sec²", + "value": "accMS2" + }, + { + "text": "Feet/sec²", + "value": "accFS2" + }, + { + "text": "G unit", + "value": "accG" + } + ], + "text": "acceleration" + }, + { + "submenu": [ + { + "text": "Becquerel (Bq)", + "value": "radbq" + }, + { + "text": "curie (Ci)", + "value": "radci" + }, + { + "text": "Gray (Gy)", + "value": "radgy" + }, + { + "text": "rad", + "value": "radrad" + }, + { + "text": "Sievert (Sv)", + "value": "radsv" + }, + { + "text": "rem", + "value": "radrem" + }, + { + "text": "Exposure (C/kg)", + "value": "radexpckg" + }, + { + "text": "roentgen (R)", + "value": "radr" + }, + { + "text": "Sievert/hour (Sv/h)", + "value": "radsvh" + } + ], + "text": "radiation" + }, + { + "submenu": [ + { + "text": "parts-per-million (ppm)", + "value": "ppm" + }, + { + "text": "parts-per-billion (ppb)", + "value": "conppb" + }, + { + "text": "nanogram per cubic metre (ng/m³)", + "value": "conngm3" + }, + { + "text": "nanogram per normal cubic metre (ng/Nm³)", + "value": "conngNm3" + }, + { + "text": "microgram per cubic metre (μg/m³)", + "value": "conμgm3" + }, + { + "text": "microgram per normal cubic metre (μg/Nm³)", + "value": "conμgNm3" + }, + { + "text": "milligram per cubic metre (mg/m³)", + "value": "conmgm3" + }, + { + "text": "milligram per normal cubic metre (mg/Nm³)", + "value": "conmgNm3" + }, + { + "text": "gram per cubic metre (g/m³)", + "value": "congm3" + }, + { + "text": "gram per normal cubic metre (g/Nm³)", + "value": "congNm3" + } + ], + "text": "concentration" + } + ] + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] @@ -337,7 +2525,1090 @@ "kind": "grafana-polystat-panel", "spec": { "pluginVersion": "", - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "grafana-polystat-panel", + "originalOptions": { + "animationModes": [ + { + "text": "Show All", + "value": "all" + }, + { + "text": "Show Triggered", + "value": "triggered" + } + ], + "colors": [ + "#299c46", + "rgba(237, 129, 40, 0.89)", + "#d44a3a" + ], + "d3DivId": "d3_svg_5", + "decimals": 2, + "displayModes": [ + { + "text": "Show All", + "value": "all" + }, + { + "text": "Show Triggered", + "value": "triggered" + } + ], + "fontSizes": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 22, + 24, + 26, + 28, + 30, + 32, + 34, + 36, + 38, + 40, + 42, + 44, + 46, + 48, + 50, + 52, + 54, + 56, + 58, + 60, + 62, + 64, + 66, + 68, + 70 + ], + "fontTypes": [ + "Open Sans", + "Arial", + "Avant Garde", + "Bookman", + "Consolas", + "Courier", + "Courier New", + "Futura", + "Garamond", + "Helvetica", + "Palatino", + "Times", + "Times New Roman", + "Verdana" + ], + "format": "none", + "notcolors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "operatorName": "avg", + "operatorOptions": [ + { + "text": "Average", + "value": "avg" + }, + { + "text": "Count", + "value": "count" + }, + { + "text": "Current", + "value": "current" + }, + { + "text": "Delta", + "value": "delta" + }, + { + "text": "Difference", + "value": "diff" + }, + { + "text": "First", + "value": "first" + }, + { + "text": "Log Min", + "value": "logmin" + }, + { + "text": "Max", + "value": "max" + }, + { + "text": "Min", + "value": "min" + }, + { + "text": "Name", + "value": "name" + }, + { + "text": "Time of Last Point", + "value": "last_time" + }, + { + "text": "Time Step", + "value": "time_step" + }, + { + "text": "Total", + "value": "total" + } + ], + "polystat": { + "animationSpeed": 2500, + "columnAutoSize": true, + "columns": "", + "defaultClickThrough": "", + "defaultClickThroughSanitize": true, + "displayLimit": 100, + "fontAutoScale": true, + "fontSize": 12, + "globalDisplayMode": "all", + "globalOperatorName": "avg", + "gradientEnabled": true, + "hexagonSortByDirection": "asc", + "hexagonSortByField": "name", + "maxMetrics": 0, + "polygonBorderColor": "black", + "polygonBorderSize": 2, + "radius": "", + "radiusAutoSize": true, + "rowAutoSize": true, + "rows": "", + "shape": "hexagon_pointed_top", + "tooltipDisplayMode": "all", + "tooltipDisplayTextTriggeredEmpty": "OK", + "tooltipFontSize": 12, + "tooltipFontType": "Open Sans", + "tooltipPrimarySortDirection": "desc", + "tooltipPrimarySortField": "thresholdLevel", + "tooltipSecondarySortDirection": "desc", + "tooltipSecondarySortField": "value", + "tooltipTimestampEnabled": true + }, + "savedComposites": [ + { + "animateMode": "all", + "clickThrough": "", + "compositeName": "comp", + "enabled": true, + "hideMembers": true, + "members": [ + { + "seriesName": "A-series" + }, + { + "seriesName": "B-series" + } + ], + "sanitizeURLEnabled": true, + "sanitizedURL": "", + "showName": true, + "showValue": true, + "thresholdLevel": 0 + } + ], + "savedOverrides": [], + "shapes": [ + { + "text": "Hexagon Pointed Top", + "value": "hexagon_pointed_top" + }, + { + "text": "Hexagon Flat Top", + "value": "hexagon_flat_top" + }, + { + "text": "Circle", + "value": "circle" + }, + { + "text": "Cross", + "value": "cross" + }, + { + "text": "Diamond", + "value": "diamond" + }, + { + "text": "Square", + "value": "square" + }, + { + "text": "Star", + "value": "star" + }, + { + "text": "Triangle", + "value": "triangle" + }, + { + "text": "Wye", + "value": "wye" + } + ], + "sortDirections": [ + { + "text": "Ascending", + "value": "asc" + }, + { + "text": "Descending", + "value": "desc" + } + ], + "sortFields": [ + { + "text": "Name", + "value": "name" + }, + { + "text": "Threshold Level", + "value": "thresholdLevel" + }, + { + "text": "Value", + "value": "value" + } + ], + "svgContainer": {}, + "thresholdStates": [ + { + "text": "ok", + "value": 0 + }, + { + "text": "warning", + "value": 1 + }, + { + "text": "critical", + "value": 2 + }, + { + "text": "custom", + "value": 3 + } + ], + "unitFormats": [ + { + "submenu": [ + { + "text": "none", + "value": "none" + }, + { + "text": "short", + "value": "short" + }, + { + "text": "percent (0-100)", + "value": "percent" + }, + { + "text": "percent (0.0-1.0)", + "value": "percentunit" + }, + { + "text": "Humidity (%H)", + "value": "humidity" + }, + { + "text": "decibel", + "value": "dB" + }, + { + "text": "hexadecimal (0x)", + "value": "hex0x" + }, + { + "text": "hexadecimal", + "value": "hex" + }, + { + "text": "scientific notation", + "value": "sci" + }, + { + "text": "locale format", + "value": "locale" + } + ], + "text": "none" + }, + { + "submenu": [ + { + "text": "Dollars ($)", + "value": "currencyUSD" + }, + { + "text": "Pounds (£)", + "value": "currencyGBP" + }, + { + "text": "Euro (€)", + "value": "currencyEUR" + }, + { + "text": "Yen (¥)", + "value": "currencyJPY" + }, + { + "text": "Rubles (₽)", + "value": "currencyRUB" + }, + { + "text": "Hryvnias (₴)", + "value": "currencyUAH" + }, + { + "text": "Real (R$)", + "value": "currencyBRL" + }, + { + "text": "Danish Krone (kr)", + "value": "currencyDKK" + }, + { + "text": "Icelandic Króna (kr)", + "value": "currencyISK" + }, + { + "text": "Norwegian Krone (kr)", + "value": "currencyNOK" + }, + { + "text": "Swedish Krona (kr)", + "value": "currencySEK" + }, + { + "text": "Czech koruna (czk)", + "value": "currencyCZK" + }, + { + "text": "Swiss franc (CHF)", + "value": "currencyCHF" + }, + { + "text": "Polish Złoty (PLN)", + "value": "currencyPLN" + }, + { + "text": "Bitcoin (฿)", + "value": "currencyBTC" + }, + { + "text": "Vietnamese Dong (VND)", + "value": "currencyVND" + }, + { + "text": "Malaysian Ringgit (RM)", + "value": "currencyMYR" + }, + { + "text": "Bulgarian Lev (BGN)", + "value": "currencyBGN" + } + ], + "text": "currency" + }, + { + "submenu": [ + { + "text": "Hertz (1/s)", + "value": "hertz" + }, + { + "text": "nanoseconds (ns)", + "value": "ns" + }, + { + "text": "microseconds (µs)", + "value": "µs" + }, + { + "text": "milliseconds (ms)", + "value": "ms" + }, + { + "text": "seconds (s)", + "value": "s" + }, + { + "text": "minutes (m)", + "value": "m" + }, + { + "text": "hours (h)", + "value": "h" + }, + { + "text": "days (d)", + "value": "d" + }, + { + "text": "duration (ms)", + "value": "dtdurationms" + }, + { + "text": "duration (s)", + "value": "dtdurations" + }, + { + "text": "duration (hh:mm:ss)", + "value": "dthms" + }, + { + "text": "Timeticks (s/100)", + "value": "timeticks" + } + ], + "text": "time" + }, + { + "submenu": [ + { + "text": "YYYY-MM-DD HH:mm:ss", + "value": "dateTimeAsIso" + }, + { + "text": "DD/MM/YYYY h:mm:ss a", + "value": "dateTimeAsUS" + }, + { + "text": "From Now", + "value": "dateTimeFromNow" + } + ], + "text": "date \u0026 time" + }, + { + "submenu": [ + { + "text": "bits", + "value": "bits" + }, + { + "text": "bytes", + "value": "bytes" + }, + { + "text": "kibibytes", + "value": "kbytes" + }, + { + "text": "mebibytes", + "value": "mbytes" + }, + { + "text": "gibibytes", + "value": "gbytes" + } + ], + "text": "data (IEC)" + }, + { + "submenu": [ + { + "text": "bits", + "value": "decbits" + }, + { + "text": "bytes", + "value": "decbytes" + }, + { + "text": "kilobytes", + "value": "deckbytes" + }, + { + "text": "megabytes", + "value": "decmbytes" + }, + { + "text": "gigabytes", + "value": "decgbytes" + } + ], + "text": "data (Metric)" + }, + { + "submenu": [ + { + "text": "packets/sec", + "value": "pps" + }, + { + "text": "bits/sec", + "value": "bps" + }, + { + "text": "bytes/sec", + "value": "Bps" + }, + { + "text": "kilobits/sec", + "value": "Kbits" + }, + { + "text": "kilobytes/sec", + "value": "KBs" + }, + { + "text": "megabits/sec", + "value": "Mbits" + }, + { + "text": "megabytes/sec", + "value": "MBs" + }, + { + "text": "gigabytes/sec", + "value": "GBs" + }, + { + "text": "gigabits/sec", + "value": "Gbits" + } + ], + "text": "data rate" + }, + { + "submenu": [ + { + "text": "hashes/sec", + "value": "Hs" + }, + { + "text": "kilohashes/sec", + "value": "KHs" + }, + { + "text": "megahashes/sec", + "value": "MHs" + }, + { + "text": "gigahashes/sec", + "value": "GHs" + }, + { + "text": "terahashes/sec", + "value": "THs" + }, + { + "text": "petahashes/sec", + "value": "PHs" + }, + { + "text": "exahashes/sec", + "value": "EHs" + } + ], + "text": "hash rate" + }, + { + "submenu": [ + { + "text": "ops/sec (ops)", + "value": "ops" + }, + { + "text": "requests/sec (rps)", + "value": "reqps" + }, + { + "text": "reads/sec (rps)", + "value": "rps" + }, + { + "text": "writes/sec (wps)", + "value": "wps" + }, + { + "text": "I/O ops/sec (iops)", + "value": "iops" + }, + { + "text": "ops/min (opm)", + "value": "opm" + }, + { + "text": "reads/min (rpm)", + "value": "rpm" + }, + { + "text": "writes/min (wpm)", + "value": "wpm" + } + ], + "text": "throughput" + }, + { + "submenu": [ + { + "text": "millimetre (mm)", + "value": "lengthmm" + }, + { + "text": "meter (m)", + "value": "lengthm" + }, + { + "text": "inch (in)", + "value": "lengthin" + }, + { + "text": "feet (ft)", + "value": "lengthft" + }, + { + "text": "kilometer (km)", + "value": "lengthkm" + }, + { + "text": "mile (mi)", + "value": "lengthmi" + } + ], + "text": "length" + }, + { + "submenu": [ + { + "text": "Square Meters (m²)", + "value": "areaM2" + }, + { + "text": "Square Feet (ft²)", + "value": "areaF2" + }, + { + "text": "Square Miles (mi²)", + "value": "areaMI2" + } + ], + "text": "area" + }, + { + "submenu": [ + { + "text": "milligram (mg)", + "value": "massmg" + }, + { + "text": "gram (g)", + "value": "massg" + }, + { + "text": "pound (lb)", + "value": "masslb" + }, + { + "text": "kilogram (kg)", + "value": "masskg" + }, + { + "text": "metric ton (t)", + "value": "masst" + } + ], + "text": "mass" + }, + { + "submenu": [ + { + "text": "metres/second (m/s)", + "value": "velocityms" + }, + { + "text": "kilometers/hour (km/h)", + "value": "velocitykmh" + }, + { + "text": "miles/hour (mph)", + "value": "velocitymph" + }, + { + "text": "knot (kn)", + "value": "velocityknot" + } + ], + "text": "velocity" + }, + { + "submenu": [ + { + "text": "millilitre (mL)", + "value": "mlitre" + }, + { + "text": "litre (L)", + "value": "litre" + }, + { + "text": "cubic metre", + "value": "m3" + }, + { + "text": "Normal cubic metre", + "value": "Nm3" + }, + { + "text": "cubic decimetre", + "value": "dm3" + }, + { + "text": "gallons", + "value": "gallons" + } + ], + "text": "volume" + }, + { + "submenu": [ + { + "text": "Watt (W)", + "value": "watt" + }, + { + "text": "Kilowatt (kW)", + "value": "kwatt" + }, + { + "text": "Milliwatt (mW)", + "value": "mwatt" + }, + { + "text": "Watt per square metre (W/m²)", + "value": "Wm2" + }, + { + "text": "Volt-ampere (VA)", + "value": "voltamp" + }, + { + "text": "Kilovolt-ampere (kVA)", + "value": "kvoltamp" + }, + { + "text": "Volt-ampere reactive (var)", + "value": "voltampreact" + }, + { + "text": "Kilovolt-ampere reactive (kvar)", + "value": "kvoltampreact" + }, + { + "text": "Watt-hour (Wh)", + "value": "watth" + }, + { + "text": "Kilowatt-hour (kWh)", + "value": "kwatth" + }, + { + "text": "Kilowatt-min (kWm)", + "value": "kwattm" + }, + { + "text": "Joule (J)", + "value": "joule" + }, + { + "text": "Electron volt (eV)", + "value": "ev" + }, + { + "text": "Ampere (A)", + "value": "amp" + }, + { + "text": "Kiloampere (kA)", + "value": "kamp" + }, + { + "text": "Milliampere (mA)", + "value": "mamp" + }, + { + "text": "Volt (V)", + "value": "volt" + }, + { + "text": "Kilovolt (kV)", + "value": "kvolt" + }, + { + "text": "Millivolt (mV)", + "value": "mvolt" + }, + { + "text": "Decibel-milliwatt (dBm)", + "value": "dBm" + }, + { + "text": "Ohm (Ω)", + "value": "ohm" + }, + { + "text": "Lumens (Lm)", + "value": "lumens" + } + ], + "text": "energy" + }, + { + "submenu": [ + { + "text": "Celsius (°C)", + "value": "celsius" + }, + { + "text": "Fahrenheit (°F)", + "value": "fahrenheit" + }, + { + "text": "Kelvin (K)", + "value": "kelvin" + } + ], + "text": "temperature" + }, + { + "submenu": [ + { + "text": "Millibars", + "value": "pressurembar" + }, + { + "text": "Bars", + "value": "pressurebar" + }, + { + "text": "Kilobars", + "value": "pressurekbar" + }, + { + "text": "Hectopascals", + "value": "pressurehpa" + }, + { + "text": "Kilopascals", + "value": "pressurekpa" + }, + { + "text": "Inches of mercury", + "value": "pressurehg" + }, + { + "text": "PSI", + "value": "pressurepsi" + } + ], + "text": "pressure" + }, + { + "submenu": [ + { + "text": "Newton-meters (Nm)", + "value": "forceNm" + }, + { + "text": "Kilonewton-meters (kNm)", + "value": "forcekNm" + }, + { + "text": "Newtons (N)", + "value": "forceN" + }, + { + "text": "Kilonewtons (kN)", + "value": "forcekN" + } + ], + "text": "force" + }, + { + "submenu": [ + { + "text": "Gallons/min (gpm)", + "value": "flowgpm" + }, + { + "text": "Cubic meters/sec (cms)", + "value": "flowcms" + }, + { + "text": "Cubic feet/sec (cfs)", + "value": "flowcfs" + }, + { + "text": "Cubic feet/min (cfm)", + "value": "flowcfm" + }, + { + "text": "Litre/hour", + "value": "litreh" + }, + { + "text": "Litre/min (l/min)", + "value": "flowlpm" + }, + { + "text": "milliLitre/min (mL/min)", + "value": "flowmlpm" + } + ], + "text": "flow" + }, + { + "submenu": [ + { + "text": "Degrees (°)", + "value": "degree" + }, + { + "text": "Radians", + "value": "radian" + }, + { + "text": "Gradian", + "value": "grad" + } + ], + "text": "angle" + }, + { + "submenu": [ + { + "text": "Meters/sec²", + "value": "accMS2" + }, + { + "text": "Feet/sec²", + "value": "accFS2" + }, + { + "text": "G unit", + "value": "accG" + } + ], + "text": "acceleration" + }, + { + "submenu": [ + { + "text": "Becquerel (Bq)", + "value": "radbq" + }, + { + "text": "curie (Ci)", + "value": "radci" + }, + { + "text": "Gray (Gy)", + "value": "radgy" + }, + { + "text": "rad", + "value": "radrad" + }, + { + "text": "Sievert (Sv)", + "value": "radsv" + }, + { + "text": "rem", + "value": "radrem" + }, + { + "text": "Exposure (C/kg)", + "value": "radexpckg" + }, + { + "text": "roentgen (R)", + "value": "radr" + }, + { + "text": "Sievert/hour (Sv/h)", + "value": "radsvh" + } + ], + "text": "radiation" + }, + { + "submenu": [ + { + "text": "parts-per-million (ppm)", + "value": "ppm" + }, + { + "text": "parts-per-billion (ppb)", + "value": "conppb" + }, + { + "text": "nanogram per cubic metre (ng/m³)", + "value": "conngm3" + }, + { + "text": "nanogram per normal cubic metre (ng/Nm³)", + "value": "conngNm3" + }, + { + "text": "microgram per cubic metre (μg/m³)", + "value": "conμgm3" + }, + { + "text": "microgram per normal cubic metre (μg/Nm³)", + "value": "conμgNm3" + }, + { + "text": "milligram per cubic metre (mg/m³)", + "value": "conmgm3" + }, + { + "text": "milligram per normal cubic metre (mg/Nm³)", + "value": "conmgNm3" + }, + { + "text": "gram per cubic metre (g/m³)", + "value": "congm3" + }, + { + "text": "gram per normal cubic metre (g/Nm³)", + "value": "congNm3" + } + ], + "text": "concentration" + } + ] + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-polystat/v0alpha1.polystat_test.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-polystat/v0alpha1.polystat_test.v42.v2beta1.json index f72f1e3b485..5916250ffd5 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-polystat/v0alpha1.polystat_test.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-polystat/v0alpha1.polystat_test.v42.v2beta1.json @@ -130,7 +130,1073 @@ "group": "grafana-polystat-panel", "version": "", "spec": { - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "grafana-polystat-panel", + "originalOptions": { + "animationModes": [ + { + "text": "Show All", + "value": "all" + }, + { + "text": "Show Triggered", + "value": "triggered" + } + ], + "colors": [ + "#299c46", + "rgba(237, 129, 40, 0.89)", + "#d44a3a" + ], + "d3DivId": "d3_svg_2", + "decimals": 2, + "displayModes": [ + { + "text": "Show All", + "value": "all" + }, + { + "text": "Show Triggered", + "value": "triggered" + } + ], + "fontSizes": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 22, + 24, + 26, + 28, + 30, + 32, + 34, + 36, + 38, + 40, + 42, + 44, + 46, + 48, + 50, + 52, + 54, + 56, + 58, + 60, + 62, + 64, + 66, + 68, + 70 + ], + "fontTypes": [ + "Open Sans", + "Arial", + "Avant Garde", + "Bookman", + "Consolas", + "Courier", + "Courier New", + "Futura", + "Garamond", + "Helvetica", + "Palatino", + "Times", + "Times New Roman", + "Verdana" + ], + "format": "none", + "notcolors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "operatorName": "avg", + "operatorOptions": [ + { + "text": "Average", + "value": "avg" + }, + { + "text": "Count", + "value": "count" + }, + { + "text": "Current", + "value": "current" + }, + { + "text": "Delta", + "value": "delta" + }, + { + "text": "Difference", + "value": "diff" + }, + { + "text": "First", + "value": "first" + }, + { + "text": "Log Min", + "value": "logmin" + }, + { + "text": "Max", + "value": "max" + }, + { + "text": "Min", + "value": "min" + }, + { + "text": "Name", + "value": "name" + }, + { + "text": "Time of Last Point", + "value": "last_time" + }, + { + "text": "Time Step", + "value": "time_step" + }, + { + "text": "Total", + "value": "total" + } + ], + "polystat": { + "animationSpeed": 2500, + "columnAutoSize": true, + "columns": 1, + "defaultClickThrough": "", + "defaultClickThroughSanitize": true, + "displayLimit": 100, + "fontAutoScale": true, + "fontSize": 12, + "globalDisplayMode": "all", + "globalOperatorName": "avg", + "gradientEnabled": true, + "hexagonSortByDirection": "asc", + "hexagonSortByField": "name", + "maxMetrics": 0, + "polygonBorderColor": "black", + "polygonBorderSize": 2, + "radius": "", + "radiusAutoSize": true, + "rowAutoSize": true, + "rows": 1, + "shape": "hexagon_pointed_top", + "tooltipDisplayMode": "all", + "tooltipDisplayTextTriggeredEmpty": "OK", + "tooltipFontSize": 12, + "tooltipFontType": "Open Sans", + "tooltipPrimarySortDirection": "desc", + "tooltipPrimarySortField": "thresholdLevel", + "tooltipSecondarySortDirection": "desc", + "tooltipSecondarySortField": "value", + "tooltipTimestampEnabled": true + }, + "savedComposites": [], + "savedOverrides": [], + "shapes": [ + { + "text": "Hexagon Pointed Top", + "value": "hexagon_pointed_top" + }, + { + "text": "Hexagon Flat Top", + "value": "hexagon_flat_top" + }, + { + "text": "Circle", + "value": "circle" + }, + { + "text": "Cross", + "value": "cross" + }, + { + "text": "Diamond", + "value": "diamond" + }, + { + "text": "Square", + "value": "square" + }, + { + "text": "Star", + "value": "star" + }, + { + "text": "Triangle", + "value": "triangle" + }, + { + "text": "Wye", + "value": "wye" + } + ], + "sortDirections": [ + { + "text": "Ascending", + "value": "asc" + }, + { + "text": "Descending", + "value": "desc" + } + ], + "sortFields": [ + { + "text": "Name", + "value": "name" + }, + { + "text": "Threshold Level", + "value": "thresholdLevel" + }, + { + "text": "Value", + "value": "value" + } + ], + "svgContainer": {}, + "thresholdStates": [ + { + "text": "ok", + "value": 0 + }, + { + "text": "warning", + "value": 1 + }, + { + "text": "critical", + "value": 2 + }, + { + "text": "custom", + "value": 3 + } + ], + "unitFormats": [ + { + "submenu": [ + { + "text": "none", + "value": "none" + }, + { + "text": "short", + "value": "short" + }, + { + "text": "percent (0-100)", + "value": "percent" + }, + { + "text": "percent (0.0-1.0)", + "value": "percentunit" + }, + { + "text": "Humidity (%H)", + "value": "humidity" + }, + { + "text": "decibel", + "value": "dB" + }, + { + "text": "hexadecimal (0x)", + "value": "hex0x" + }, + { + "text": "hexadecimal", + "value": "hex" + }, + { + "text": "scientific notation", + "value": "sci" + }, + { + "text": "locale format", + "value": "locale" + } + ], + "text": "none" + }, + { + "submenu": [ + { + "text": "Dollars ($)", + "value": "currencyUSD" + }, + { + "text": "Pounds (£)", + "value": "currencyGBP" + }, + { + "text": "Euro (€)", + "value": "currencyEUR" + }, + { + "text": "Yen (¥)", + "value": "currencyJPY" + }, + { + "text": "Rubles (₽)", + "value": "currencyRUB" + }, + { + "text": "Hryvnias (₴)", + "value": "currencyUAH" + }, + { + "text": "Real (R$)", + "value": "currencyBRL" + }, + { + "text": "Danish Krone (kr)", + "value": "currencyDKK" + }, + { + "text": "Icelandic Króna (kr)", + "value": "currencyISK" + }, + { + "text": "Norwegian Krone (kr)", + "value": "currencyNOK" + }, + { + "text": "Swedish Krona (kr)", + "value": "currencySEK" + }, + { + "text": "Czech koruna (czk)", + "value": "currencyCZK" + }, + { + "text": "Swiss franc (CHF)", + "value": "currencyCHF" + }, + { + "text": "Polish Złoty (PLN)", + "value": "currencyPLN" + }, + { + "text": "Bitcoin (฿)", + "value": "currencyBTC" + }, + { + "text": "Vietnamese Dong (VND)", + "value": "currencyVND" + }, + { + "text": "Malaysian Ringgit (RM)", + "value": "currencyMYR" + }, + { + "text": "Bulgarian Lev (BGN)", + "value": "currencyBGN" + } + ], + "text": "currency" + }, + { + "submenu": [ + { + "text": "Hertz (1/s)", + "value": "hertz" + }, + { + "text": "nanoseconds (ns)", + "value": "ns" + }, + { + "text": "microseconds (µs)", + "value": "µs" + }, + { + "text": "milliseconds (ms)", + "value": "ms" + }, + { + "text": "seconds (s)", + "value": "s" + }, + { + "text": "minutes (m)", + "value": "m" + }, + { + "text": "hours (h)", + "value": "h" + }, + { + "text": "days (d)", + "value": "d" + }, + { + "text": "duration (ms)", + "value": "dtdurationms" + }, + { + "text": "duration (s)", + "value": "dtdurations" + }, + { + "text": "duration (hh:mm:ss)", + "value": "dthms" + }, + { + "text": "Timeticks (s/100)", + "value": "timeticks" + } + ], + "text": "time" + }, + { + "submenu": [ + { + "text": "YYYY-MM-DD HH:mm:ss", + "value": "dateTimeAsIso" + }, + { + "text": "DD/MM/YYYY h:mm:ss a", + "value": "dateTimeAsUS" + }, + { + "text": "From Now", + "value": "dateTimeFromNow" + } + ], + "text": "date \u0026 time" + }, + { + "submenu": [ + { + "text": "bits", + "value": "bits" + }, + { + "text": "bytes", + "value": "bytes" + }, + { + "text": "kibibytes", + "value": "kbytes" + }, + { + "text": "mebibytes", + "value": "mbytes" + }, + { + "text": "gibibytes", + "value": "gbytes" + } + ], + "text": "data (IEC)" + }, + { + "submenu": [ + { + "text": "bits", + "value": "decbits" + }, + { + "text": "bytes", + "value": "decbytes" + }, + { + "text": "kilobytes", + "value": "deckbytes" + }, + { + "text": "megabytes", + "value": "decmbytes" + }, + { + "text": "gigabytes", + "value": "decgbytes" + } + ], + "text": "data (Metric)" + }, + { + "submenu": [ + { + "text": "packets/sec", + "value": "pps" + }, + { + "text": "bits/sec", + "value": "bps" + }, + { + "text": "bytes/sec", + "value": "Bps" + }, + { + "text": "kilobits/sec", + "value": "Kbits" + }, + { + "text": "kilobytes/sec", + "value": "KBs" + }, + { + "text": "megabits/sec", + "value": "Mbits" + }, + { + "text": "megabytes/sec", + "value": "MBs" + }, + { + "text": "gigabytes/sec", + "value": "GBs" + }, + { + "text": "gigabits/sec", + "value": "Gbits" + } + ], + "text": "data rate" + }, + { + "submenu": [ + { + "text": "hashes/sec", + "value": "Hs" + }, + { + "text": "kilohashes/sec", + "value": "KHs" + }, + { + "text": "megahashes/sec", + "value": "MHs" + }, + { + "text": "gigahashes/sec", + "value": "GHs" + }, + { + "text": "terahashes/sec", + "value": "THs" + }, + { + "text": "petahashes/sec", + "value": "PHs" + }, + { + "text": "exahashes/sec", + "value": "EHs" + } + ], + "text": "hash rate" + }, + { + "submenu": [ + { + "text": "ops/sec (ops)", + "value": "ops" + }, + { + "text": "requests/sec (rps)", + "value": "reqps" + }, + { + "text": "reads/sec (rps)", + "value": "rps" + }, + { + "text": "writes/sec (wps)", + "value": "wps" + }, + { + "text": "I/O ops/sec (iops)", + "value": "iops" + }, + { + "text": "ops/min (opm)", + "value": "opm" + }, + { + "text": "requests/min (rpm)", + "value": "reqpm" + }, + { + "text": "reads/min (rpm)", + "value": "rpm" + }, + { + "text": "writes/min (wpm)", + "value": "wpm" + } + ], + "text": "throughput" + }, + { + "submenu": [ + { + "text": "millimetre (mm)", + "value": "lengthmm" + }, + { + "text": "meter (m)", + "value": "lengthm" + }, + { + "text": "inch (in)", + "value": "lengthin" + }, + { + "text": "feet (ft)", + "value": "lengthft" + }, + { + "text": "kilometer (km)", + "value": "lengthkm" + }, + { + "text": "mile (mi)", + "value": "lengthmi" + } + ], + "text": "length" + }, + { + "submenu": [ + { + "text": "Square Meters (m²)", + "value": "areaM2" + }, + { + "text": "Square Feet (ft²)", + "value": "areaF2" + }, + { + "text": "Square Miles (mi²)", + "value": "areaMI2" + } + ], + "text": "area" + }, + { + "submenu": [ + { + "text": "milligram (mg)", + "value": "massmg" + }, + { + "text": "gram (g)", + "value": "massg" + }, + { + "text": "pound (lb)", + "value": "masslb" + }, + { + "text": "kilogram (kg)", + "value": "masskg" + }, + { + "text": "metric ton (t)", + "value": "masst" + } + ], + "text": "mass" + }, + { + "submenu": [ + { + "text": "metres/second (m/s)", + "value": "velocityms" + }, + { + "text": "kilometers/hour (km/h)", + "value": "velocitykmh" + }, + { + "text": "miles/hour (mph)", + "value": "velocitymph" + }, + { + "text": "knot (kn)", + "value": "velocityknot" + } + ], + "text": "velocity" + }, + { + "submenu": [ + { + "text": "millilitre (mL)", + "value": "mlitre" + }, + { + "text": "litre (L)", + "value": "litre" + }, + { + "text": "cubic metre", + "value": "m3" + }, + { + "text": "Normal cubic metre", + "value": "Nm3" + }, + { + "text": "cubic decimetre", + "value": "dm3" + }, + { + "text": "gallons", + "value": "gallons" + } + ], + "text": "volume" + }, + { + "submenu": [ + { + "text": "Watt (W)", + "value": "watt" + }, + { + "text": "Kilowatt (kW)", + "value": "kwatt" + }, + { + "text": "Milliwatt (mW)", + "value": "mwatt" + }, + { + "text": "Watt per square metre (W/m²)", + "value": "Wm2" + }, + { + "text": "Volt-ampere (VA)", + "value": "voltamp" + }, + { + "text": "Kilovolt-ampere (kVA)", + "value": "kvoltamp" + }, + { + "text": "Volt-ampere reactive (var)", + "value": "voltampreact" + }, + { + "text": "Kilovolt-ampere reactive (kvar)", + "value": "kvoltampreact" + }, + { + "text": "Watt-hour (Wh)", + "value": "watth" + }, + { + "text": "Kilowatt-hour (kWh)", + "value": "kwatth" + }, + { + "text": "Kilowatt-min (kWm)", + "value": "kwattm" + }, + { + "text": "Joule (J)", + "value": "joule" + }, + { + "text": "Electron volt (eV)", + "value": "ev" + }, + { + "text": "Ampere (A)", + "value": "amp" + }, + { + "text": "Kiloampere (kA)", + "value": "kamp" + }, + { + "text": "Milliampere (mA)", + "value": "mamp" + }, + { + "text": "Volt (V)", + "value": "volt" + }, + { + "text": "Kilovolt (kV)", + "value": "kvolt" + }, + { + "text": "Millivolt (mV)", + "value": "mvolt" + }, + { + "text": "Decibel-milliwatt (dBm)", + "value": "dBm" + }, + { + "text": "Ohm (Ω)", + "value": "ohm" + }, + { + "text": "Lumens (Lm)", + "value": "lumens" + } + ], + "text": "energy" + }, + { + "submenu": [ + { + "text": "Celsius (°C)", + "value": "celsius" + }, + { + "text": "Fahrenheit (°F)", + "value": "fahrenheit" + }, + { + "text": "Kelvin (K)", + "value": "kelvin" + } + ], + "text": "temperature" + }, + { + "submenu": [ + { + "text": "Millibars", + "value": "pressurembar" + }, + { + "text": "Bars", + "value": "pressurebar" + }, + { + "text": "Kilobars", + "value": "pressurekbar" + }, + { + "text": "Hectopascals", + "value": "pressurehpa" + }, + { + "text": "Kilopascals", + "value": "pressurekpa" + }, + { + "text": "Inches of mercury", + "value": "pressurehg" + }, + { + "text": "PSI", + "value": "pressurepsi" + } + ], + "text": "pressure" + }, + { + "submenu": [ + { + "text": "Newton-meters (Nm)", + "value": "forceNm" + }, + { + "text": "Kilonewton-meters (kNm)", + "value": "forcekNm" + }, + { + "text": "Newtons (N)", + "value": "forceN" + }, + { + "text": "Kilonewtons (kN)", + "value": "forcekN" + } + ], + "text": "force" + }, + { + "submenu": [ + { + "text": "Gallons/min (gpm)", + "value": "flowgpm" + }, + { + "text": "Cubic meters/sec (cms)", + "value": "flowcms" + }, + { + "text": "Cubic feet/sec (cfs)", + "value": "flowcfs" + }, + { + "text": "Cubic feet/min (cfm)", + "value": "flowcfm" + }, + { + "text": "Litre/hour", + "value": "litreh" + }, + { + "text": "Litre/min (l/min)", + "value": "flowlpm" + }, + { + "text": "milliLitre/min (mL/min)", + "value": "flowmlpm" + } + ], + "text": "flow" + }, + { + "submenu": [ + { + "text": "Degrees (°)", + "value": "degree" + }, + { + "text": "Radians", + "value": "radian" + }, + { + "text": "Gradian", + "value": "grad" + } + ], + "text": "angle" + }, + { + "submenu": [ + { + "text": "Meters/sec²", + "value": "accMS2" + }, + { + "text": "Feet/sec²", + "value": "accFS2" + }, + { + "text": "G unit", + "value": "accG" + } + ], + "text": "acceleration" + }, + { + "submenu": [ + { + "text": "Becquerel (Bq)", + "value": "radbq" + }, + { + "text": "curie (Ci)", + "value": "radci" + }, + { + "text": "Gray (Gy)", + "value": "radgy" + }, + { + "text": "rad", + "value": "radrad" + }, + { + "text": "Sievert (Sv)", + "value": "radsv" + }, + { + "text": "rem", + "value": "radrem" + }, + { + "text": "Exposure (C/kg)", + "value": "radexpckg" + }, + { + "text": "roentgen (R)", + "value": "radr" + }, + { + "text": "Sievert/hour (Sv/h)", + "value": "radsvh" + } + ], + "text": "radiation" + }, + { + "submenu": [ + { + "text": "parts-per-million (ppm)", + "value": "ppm" + }, + { + "text": "parts-per-billion (ppb)", + "value": "conppb" + }, + { + "text": "nanogram per cubic metre (ng/m³)", + "value": "conngm3" + }, + { + "text": "nanogram per normal cubic metre (ng/Nm³)", + "value": "conngNm3" + }, + { + "text": "microgram per cubic metre (μg/m³)", + "value": "conμgm3" + }, + { + "text": "microgram per normal cubic metre (μg/Nm³)", + "value": "conμgNm3" + }, + { + "text": "milligram per cubic metre (mg/m³)", + "value": "conmgm3" + }, + { + "text": "milligram per normal cubic metre (mg/Nm³)", + "value": "conmgNm3" + }, + { + "text": "gram per cubic metre (g/m³)", + "value": "congm3" + }, + { + "text": "gram per normal cubic metre (g/Nm³)", + "value": "congNm3" + } + ], + "text": "concentration" + } + ] + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] @@ -250,7 +1316,1129 @@ "group": "grafana-polystat-panel", "version": "", "spec": { - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "grafana-polystat-panel", + "originalOptions": { + "animationModes": [ + { + "text": "Show All", + "value": "all" + }, + { + "text": "Show Triggered", + "value": "triggered" + } + ], + "colors": [ + "#299c46", + "rgba(237, 129, 40, 0.89)", + "#d44a3a" + ], + "d3DivId": "d3_svg_4", + "decimals": 2, + "displayModes": [ + { + "text": "Show All", + "value": "all" + }, + { + "text": "Show Triggered", + "value": "triggered" + } + ], + "fontSizes": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 22, + 24, + 26, + 28, + 30, + 32, + 34, + 36, + 38, + 40, + 42, + 44, + 46, + 48, + 50, + 52, + 54, + 56, + 58, + 60, + 62, + 64, + 66, + 68, + 70 + ], + "fontTypes": [ + "Open Sans", + "Arial", + "Avant Garde", + "Bookman", + "Consolas", + "Courier", + "Courier New", + "Futura", + "Garamond", + "Helvetica", + "Palatino", + "Times", + "Times New Roman", + "Verdana" + ], + "format": "none", + "notcolors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "operatorName": "avg", + "operatorOptions": [ + { + "text": "Average", + "value": "avg" + }, + { + "text": "Count", + "value": "count" + }, + { + "text": "Current", + "value": "current" + }, + { + "text": "Delta", + "value": "delta" + }, + { + "text": "Difference", + "value": "diff" + }, + { + "text": "First", + "value": "first" + }, + { + "text": "Log Min", + "value": "logmin" + }, + { + "text": "Max", + "value": "max" + }, + { + "text": "Min", + "value": "min" + }, + { + "text": "Name", + "value": "name" + }, + { + "text": "Time of Last Point", + "value": "last_time" + }, + { + "text": "Time Step", + "value": "time_step" + }, + { + "text": "Total", + "value": "total" + } + ], + "polystat": { + "animationSpeed": 2500, + "columnAutoSize": true, + "columns": "", + "defaultClickThrough": "", + "defaultClickThroughSanitize": true, + "displayLimit": 100, + "fontAutoScale": true, + "fontSize": 12, + "globalDisplayMode": "all", + "globalOperatorName": "avg", + "gradientEnabled": true, + "hexagonSortByDirection": "asc", + "hexagonSortByField": "name", + "maxMetrics": 0, + "polygonBorderColor": "black", + "polygonBorderSize": 2, + "radius": "", + "radiusAutoSize": true, + "rowAutoSize": true, + "rows": "", + "shape": "hexagon_pointed_top", + "tooltipDisplayMode": "all", + "tooltipDisplayTextTriggeredEmpty": "OK", + "tooltipFontSize": 12, + "tooltipFontType": "Open Sans", + "tooltipPrimarySortDirection": "desc", + "tooltipPrimarySortField": "thresholdLevel", + "tooltipSecondarySortDirection": "desc", + "tooltipSecondarySortField": "value", + "tooltipTimestampEnabled": true + }, + "savedComposites": [], + "savedOverrides": [], + "shapes": [ + { + "text": "Hexagon Pointed Top", + "value": "hexagon_pointed_top" + }, + { + "text": "Hexagon Flat Top", + "value": "hexagon_flat_top" + }, + { + "text": "Circle", + "value": "circle" + }, + { + "text": "Cross", + "value": "cross" + }, + { + "text": "Diamond", + "value": "diamond" + }, + { + "text": "Square", + "value": "square" + }, + { + "text": "Star", + "value": "star" + }, + { + "text": "Triangle", + "value": "triangle" + }, + { + "text": "Wye", + "value": "wye" + } + ], + "sortDirections": [ + { + "text": "Ascending", + "value": "asc" + }, + { + "text": "Descending", + "value": "desc" + } + ], + "sortFields": [ + { + "text": "Name", + "value": "name" + }, + { + "text": "Threshold Level", + "value": "thresholdLevel" + }, + { + "text": "Value", + "value": "value" + } + ], + "svgContainer": {}, + "thresholdStates": [ + { + "text": "ok", + "value": 0 + }, + { + "text": "warning", + "value": 1 + }, + { + "text": "critical", + "value": 2 + }, + { + "text": "custom", + "value": 3 + } + ], + "unitFormats": [ + { + "submenu": [ + { + "text": "none", + "value": "none" + }, + { + "text": "short", + "value": "short" + }, + { + "text": "percent (0-100)", + "value": "percent" + }, + { + "text": "percent (0.0-1.0)", + "value": "percentunit" + }, + { + "text": "Humidity (%H)", + "value": "humidity" + }, + { + "text": "decibel", + "value": "dB" + }, + { + "text": "hexadecimal (0x)", + "value": "hex0x" + }, + { + "text": "hexadecimal", + "value": "hex" + }, + { + "text": "scientific notation", + "value": "sci" + }, + { + "text": "locale format", + "value": "locale" + } + ], + "text": "none" + }, + { + "submenu": [ + { + "text": "Dollars ($)", + "value": "currencyUSD" + }, + { + "text": "Pounds (£)", + "value": "currencyGBP" + }, + { + "text": "Euro (€)", + "value": "currencyEUR" + }, + { + "text": "Yen (¥)", + "value": "currencyJPY" + }, + { + "text": "Rubles (₽)", + "value": "currencyRUB" + }, + { + "text": "Hryvnias (₴)", + "value": "currencyUAH" + }, + { + "text": "Real (R$)", + "value": "currencyBRL" + }, + { + "text": "Danish Krone (kr)", + "value": "currencyDKK" + }, + { + "text": "Icelandic Króna (kr)", + "value": "currencyISK" + }, + { + "text": "Norwegian Krone (kr)", + "value": "currencyNOK" + }, + { + "text": "Swedish Krona (kr)", + "value": "currencySEK" + }, + { + "text": "Czech koruna (czk)", + "value": "currencyCZK" + }, + { + "text": "Swiss franc (CHF)", + "value": "currencyCHF" + }, + { + "text": "Polish Złoty (PLN)", + "value": "currencyPLN" + }, + { + "text": "Bitcoin (฿)", + "value": "currencyBTC" + }, + { + "text": "Milli Bitcoin (mBTC)", + "value": "currencymBTC" + }, + { + "text": "Micro Bitcoin (μBTC)", + "value": "currencyμBTC" + }, + { + "text": "Vietnamese Dong (VND)", + "value": "currencyVND" + }, + { + "text": "Turkish Lira (₺)", + "value": "currencyTRY" + }, + { + "text": "Malaysian Ringgit (RM)", + "value": "currencyMYR" + }, + { + "text": "CFP franc (XPF)", + "value": "currencyXPF" + }, + { + "text": "Bulgarian Lev (BGN)", + "value": "currencyBGN" + } + ], + "text": "currency" + }, + { + "submenu": [ + { + "text": "Hertz (1/s)", + "value": "hertz" + }, + { + "text": "nanoseconds (ns)", + "value": "ns" + }, + { + "text": "microseconds (µs)", + "value": "µs" + }, + { + "text": "milliseconds (ms)", + "value": "ms" + }, + { + "text": "seconds (s)", + "value": "s" + }, + { + "text": "minutes (m)", + "value": "m" + }, + { + "text": "hours (h)", + "value": "h" + }, + { + "text": "days (d)", + "value": "d" + }, + { + "text": "duration (ms)", + "value": "dtdurationms" + }, + { + "text": "duration (s)", + "value": "dtdurations" + }, + { + "text": "duration (hh:mm:ss)", + "value": "dthms" + }, + { + "text": "Timeticks (s/100)", + "value": "timeticks" + } + ], + "text": "time" + }, + { + "submenu": [ + { + "text": "YYYY-MM-DD HH:mm:ss", + "value": "dateTimeAsIso" + }, + { + "text": "DD/MM/YYYY h:mm:ss a", + "value": "dateTimeAsUS" + }, + { + "text": "From Now", + "value": "dateTimeFromNow" + } + ], + "text": "date \u0026 time" + }, + { + "submenu": [ + { + "text": "bits", + "value": "bits" + }, + { + "text": "bytes", + "value": "bytes" + }, + { + "text": "kibibytes", + "value": "kbytes" + }, + { + "text": "mebibytes", + "value": "mbytes" + }, + { + "text": "gibibytes", + "value": "gbytes" + } + ], + "text": "data (IEC)" + }, + { + "submenu": [ + { + "text": "bits", + "value": "decbits" + }, + { + "text": "bytes", + "value": "decbytes" + }, + { + "text": "kilobytes", + "value": "deckbytes" + }, + { + "text": "megabytes", + "value": "decmbytes" + }, + { + "text": "gigabytes", + "value": "decgbytes" + } + ], + "text": "data (Metric)" + }, + { + "submenu": [ + { + "text": "packets/sec", + "value": "pps" + }, + { + "text": "bits/sec", + "value": "bps" + }, + { + "text": "bytes/sec", + "value": "Bps" + }, + { + "text": "kilobits/sec", + "value": "Kbits" + }, + { + "text": "kilobytes/sec", + "value": "KBs" + }, + { + "text": "megabits/sec", + "value": "Mbits" + }, + { + "text": "megabytes/sec", + "value": "MBs" + }, + { + "text": "gigabytes/sec", + "value": "GBs" + }, + { + "text": "gigabits/sec", + "value": "Gbits" + } + ], + "text": "data rate" + }, + { + "submenu": [ + { + "text": "hashes/sec", + "value": "Hs" + }, + { + "text": "kilohashes/sec", + "value": "KHs" + }, + { + "text": "megahashes/sec", + "value": "MHs" + }, + { + "text": "gigahashes/sec", + "value": "GHs" + }, + { + "text": "terahashes/sec", + "value": "THs" + }, + { + "text": "petahashes/sec", + "value": "PHs" + }, + { + "text": "exahashes/sec", + "value": "EHs" + } + ], + "text": "hash rate" + }, + { + "submenu": [ + { + "text": "counts/sec (cps)", + "value": "cps" + }, + { + "text": "ops/sec (ops)", + "value": "ops" + }, + { + "text": "requests/sec (rps)", + "value": "reqps" + }, + { + "text": "reads/sec (rps)", + "value": "rps" + }, + { + "text": "writes/sec (wps)", + "value": "wps" + }, + { + "text": "I/O ops/sec (iops)", + "value": "iops" + }, + { + "text": "events/sec (eps)", + "value": "eps" + }, + { + "text": "messages/sec (mps)", + "value": "mps" + }, + { + "text": "records/sec (rps)", + "value": "recps" + }, + { + "text": "rows/sec (rps)", + "value": "rowsps" + }, + { + "text": "counts/min (cpm)", + "value": "cpm" + }, + { + "text": "ops/min (opm)", + "value": "opm" + }, + { + "text": "requests/min (rpm)", + "value": "reqpm" + }, + { + "text": "reads/min (rpm)", + "value": "rpm" + }, + { + "text": "writes/min (wpm)", + "value": "wpm" + }, + { + "text": "events/min (epm)", + "value": "epm" + }, + { + "text": "messages/min (mpm)", + "value": "mpm" + }, + { + "text": "records/min (rpm)", + "value": "recpm" + }, + { + "text": "rows/min (rpm)", + "value": "rowspm" + } + ], + "text": "throughput" + }, + { + "submenu": [ + { + "text": "millimetre (mm)", + "value": "lengthmm" + }, + { + "text": "meter (m)", + "value": "lengthm" + }, + { + "text": "inch (in)", + "value": "lengthin" + }, + { + "text": "feet (ft)", + "value": "lengthft" + }, + { + "text": "kilometer (km)", + "value": "lengthkm" + }, + { + "text": "mile (mi)", + "value": "lengthmi" + } + ], + "text": "length" + }, + { + "submenu": [ + { + "text": "Square Meters (m²)", + "value": "areaM2" + }, + { + "text": "Square Feet (ft²)", + "value": "areaF2" + }, + { + "text": "Square Miles (mi²)", + "value": "areaMI2" + } + ], + "text": "area" + }, + { + "submenu": [ + { + "text": "milligram (mg)", + "value": "massmg" + }, + { + "text": "gram (g)", + "value": "massg" + }, + { + "text": "pound (lb)", + "value": "masslb" + }, + { + "text": "kilogram (kg)", + "value": "masskg" + }, + { + "text": "metric ton (t)", + "value": "masst" + } + ], + "text": "mass" + }, + { + "submenu": [ + { + "text": "metres/second (m/s)", + "value": "velocityms" + }, + { + "text": "kilometers/hour (km/h)", + "value": "velocitykmh" + }, + { + "text": "miles/hour (mph)", + "value": "velocitymph" + }, + { + "text": "knot (kn)", + "value": "velocityknot" + } + ], + "text": "velocity" + }, + { + "submenu": [ + { + "text": "millilitre (mL)", + "value": "mlitre" + }, + { + "text": "litre (L)", + "value": "litre" + }, + { + "text": "cubic metre", + "value": "m3" + }, + { + "text": "Normal cubic metre", + "value": "Nm3" + }, + { + "text": "cubic decimetre", + "value": "dm3" + }, + { + "text": "gallons", + "value": "gallons" + } + ], + "text": "volume" + }, + { + "submenu": [ + { + "text": "Watt (W)", + "value": "watt" + }, + { + "text": "Kilowatt (kW)", + "value": "kwatt" + }, + { + "text": "Milliwatt (mW)", + "value": "mwatt" + }, + { + "text": "Watt per square metre (W/m²)", + "value": "Wm2" + }, + { + "text": "Volt-Ampere (VA)", + "value": "voltamp" + }, + { + "text": "Kilovolt-Ampere (kVA)", + "value": "kvoltamp" + }, + { + "text": "Volt-Ampere reactive (VAr)", + "value": "voltampreact" + }, + { + "text": "Kilovolt-Ampere reactive (kVAr)", + "value": "kvoltampreact" + }, + { + "text": "Watt-hour (Wh)", + "value": "watth" + }, + { + "text": "Kilowatt-hour (kWh)", + "value": "kwatth" + }, + { + "text": "Kilowatt-min (kWm)", + "value": "kwattm" + }, + { + "text": "Joule (J)", + "value": "joule" + }, + { + "text": "Electron volt (eV)", + "value": "ev" + }, + { + "text": "Ampere (A)", + "value": "amp" + }, + { + "text": "Kiloampere (kA)", + "value": "kamp" + }, + { + "text": "Milliampere (mA)", + "value": "mamp" + }, + { + "text": "Volt (V)", + "value": "volt" + }, + { + "text": "Kilovolt (kV)", + "value": "kvolt" + }, + { + "text": "Millivolt (mV)", + "value": "mvolt" + }, + { + "text": "Decibel-milliwatt (dBm)", + "value": "dBm" + }, + { + "text": "Ohm (Ω)", + "value": "ohm" + }, + { + "text": "Lumens (Lm)", + "value": "lumens" + } + ], + "text": "energy" + }, + { + "submenu": [ + { + "text": "Celsius (°C)", + "value": "celsius" + }, + { + "text": "Fahrenheit (°F)", + "value": "fahrenheit" + }, + { + "text": "Kelvin (K)", + "value": "kelvin" + } + ], + "text": "temperature" + }, + { + "submenu": [ + { + "text": "Millibars", + "value": "pressurembar" + }, + { + "text": "Bars", + "value": "pressurebar" + }, + { + "text": "Kilobars", + "value": "pressurekbar" + }, + { + "text": "Hectopascals", + "value": "pressurehpa" + }, + { + "text": "Kilopascals", + "value": "pressurekpa" + }, + { + "text": "Inches of mercury", + "value": "pressurehg" + }, + { + "text": "PSI", + "value": "pressurepsi" + } + ], + "text": "pressure" + }, + { + "submenu": [ + { + "text": "Newton-meters (Nm)", + "value": "forceNm" + }, + { + "text": "Kilonewton-meters (kNm)", + "value": "forcekNm" + }, + { + "text": "Newtons (N)", + "value": "forceN" + }, + { + "text": "Kilonewtons (kN)", + "value": "forcekN" + } + ], + "text": "force" + }, + { + "submenu": [ + { + "text": "Gallons/min (gpm)", + "value": "flowgpm" + }, + { + "text": "Cubic meters/sec (cms)", + "value": "flowcms" + }, + { + "text": "Cubic feet/sec (cfs)", + "value": "flowcfs" + }, + { + "text": "Cubic feet/min (cfm)", + "value": "flowcfm" + }, + { + "text": "Litre/hour", + "value": "litreh" + }, + { + "text": "Litre/min (l/min)", + "value": "flowlpm" + }, + { + "text": "milliLitre/min (mL/min)", + "value": "flowmlpm" + } + ], + "text": "flow" + }, + { + "submenu": [ + { + "text": "Degrees (°)", + "value": "degree" + }, + { + "text": "Radians", + "value": "radian" + }, + { + "text": "Gradian", + "value": "grad" + } + ], + "text": "angle" + }, + { + "submenu": [ + { + "text": "Meters/sec²", + "value": "accMS2" + }, + { + "text": "Feet/sec²", + "value": "accFS2" + }, + { + "text": "G unit", + "value": "accG" + } + ], + "text": "acceleration" + }, + { + "submenu": [ + { + "text": "Becquerel (Bq)", + "value": "radbq" + }, + { + "text": "curie (Ci)", + "value": "radci" + }, + { + "text": "Gray (Gy)", + "value": "radgy" + }, + { + "text": "rad", + "value": "radrad" + }, + { + "text": "Sievert (Sv)", + "value": "radsv" + }, + { + "text": "rem", + "value": "radrem" + }, + { + "text": "Exposure (C/kg)", + "value": "radexpckg" + }, + { + "text": "roentgen (R)", + "value": "radr" + }, + { + "text": "Sievert/hour (Sv/h)", + "value": "radsvh" + } + ], + "text": "radiation" + }, + { + "submenu": [ + { + "text": "parts-per-million (ppm)", + "value": "ppm" + }, + { + "text": "parts-per-billion (ppb)", + "value": "conppb" + }, + { + "text": "nanogram per cubic metre (ng/m³)", + "value": "conngm3" + }, + { + "text": "nanogram per normal cubic metre (ng/Nm³)", + "value": "conngNm3" + }, + { + "text": "microgram per cubic metre (μg/m³)", + "value": "conμgm3" + }, + { + "text": "microgram per normal cubic metre (μg/Nm³)", + "value": "conμgNm3" + }, + { + "text": "milligram per cubic metre (mg/m³)", + "value": "conmgm3" + }, + { + "text": "milligram per normal cubic metre (mg/Nm³)", + "value": "conmgNm3" + }, + { + "text": "gram per cubic metre (g/m³)", + "value": "congm3" + }, + { + "text": "gram per normal cubic metre (g/Nm³)", + "value": "congNm3" + } + ], + "text": "concentration" + } + ] + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] @@ -370,7 +2558,1090 @@ "group": "grafana-polystat-panel", "version": "", "spec": { - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "grafana-polystat-panel", + "originalOptions": { + "animationModes": [ + { + "text": "Show All", + "value": "all" + }, + { + "text": "Show Triggered", + "value": "triggered" + } + ], + "colors": [ + "#299c46", + "rgba(237, 129, 40, 0.89)", + "#d44a3a" + ], + "d3DivId": "d3_svg_5", + "decimals": 2, + "displayModes": [ + { + "text": "Show All", + "value": "all" + }, + { + "text": "Show Triggered", + "value": "triggered" + } + ], + "fontSizes": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 22, + 24, + 26, + 28, + 30, + 32, + 34, + 36, + 38, + 40, + 42, + 44, + 46, + 48, + 50, + 52, + 54, + 56, + 58, + 60, + 62, + 64, + 66, + 68, + 70 + ], + "fontTypes": [ + "Open Sans", + "Arial", + "Avant Garde", + "Bookman", + "Consolas", + "Courier", + "Courier New", + "Futura", + "Garamond", + "Helvetica", + "Palatino", + "Times", + "Times New Roman", + "Verdana" + ], + "format": "none", + "notcolors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "operatorName": "avg", + "operatorOptions": [ + { + "text": "Average", + "value": "avg" + }, + { + "text": "Count", + "value": "count" + }, + { + "text": "Current", + "value": "current" + }, + { + "text": "Delta", + "value": "delta" + }, + { + "text": "Difference", + "value": "diff" + }, + { + "text": "First", + "value": "first" + }, + { + "text": "Log Min", + "value": "logmin" + }, + { + "text": "Max", + "value": "max" + }, + { + "text": "Min", + "value": "min" + }, + { + "text": "Name", + "value": "name" + }, + { + "text": "Time of Last Point", + "value": "last_time" + }, + { + "text": "Time Step", + "value": "time_step" + }, + { + "text": "Total", + "value": "total" + } + ], + "polystat": { + "animationSpeed": 2500, + "columnAutoSize": true, + "columns": "", + "defaultClickThrough": "", + "defaultClickThroughSanitize": true, + "displayLimit": 100, + "fontAutoScale": true, + "fontSize": 12, + "globalDisplayMode": "all", + "globalOperatorName": "avg", + "gradientEnabled": true, + "hexagonSortByDirection": "asc", + "hexagonSortByField": "name", + "maxMetrics": 0, + "polygonBorderColor": "black", + "polygonBorderSize": 2, + "radius": "", + "radiusAutoSize": true, + "rowAutoSize": true, + "rows": "", + "shape": "hexagon_pointed_top", + "tooltipDisplayMode": "all", + "tooltipDisplayTextTriggeredEmpty": "OK", + "tooltipFontSize": 12, + "tooltipFontType": "Open Sans", + "tooltipPrimarySortDirection": "desc", + "tooltipPrimarySortField": "thresholdLevel", + "tooltipSecondarySortDirection": "desc", + "tooltipSecondarySortField": "value", + "tooltipTimestampEnabled": true + }, + "savedComposites": [ + { + "animateMode": "all", + "clickThrough": "", + "compositeName": "comp", + "enabled": true, + "hideMembers": true, + "members": [ + { + "seriesName": "A-series" + }, + { + "seriesName": "B-series" + } + ], + "sanitizeURLEnabled": true, + "sanitizedURL": "", + "showName": true, + "showValue": true, + "thresholdLevel": 0 + } + ], + "savedOverrides": [], + "shapes": [ + { + "text": "Hexagon Pointed Top", + "value": "hexagon_pointed_top" + }, + { + "text": "Hexagon Flat Top", + "value": "hexagon_flat_top" + }, + { + "text": "Circle", + "value": "circle" + }, + { + "text": "Cross", + "value": "cross" + }, + { + "text": "Diamond", + "value": "diamond" + }, + { + "text": "Square", + "value": "square" + }, + { + "text": "Star", + "value": "star" + }, + { + "text": "Triangle", + "value": "triangle" + }, + { + "text": "Wye", + "value": "wye" + } + ], + "sortDirections": [ + { + "text": "Ascending", + "value": "asc" + }, + { + "text": "Descending", + "value": "desc" + } + ], + "sortFields": [ + { + "text": "Name", + "value": "name" + }, + { + "text": "Threshold Level", + "value": "thresholdLevel" + }, + { + "text": "Value", + "value": "value" + } + ], + "svgContainer": {}, + "thresholdStates": [ + { + "text": "ok", + "value": 0 + }, + { + "text": "warning", + "value": 1 + }, + { + "text": "critical", + "value": 2 + }, + { + "text": "custom", + "value": 3 + } + ], + "unitFormats": [ + { + "submenu": [ + { + "text": "none", + "value": "none" + }, + { + "text": "short", + "value": "short" + }, + { + "text": "percent (0-100)", + "value": "percent" + }, + { + "text": "percent (0.0-1.0)", + "value": "percentunit" + }, + { + "text": "Humidity (%H)", + "value": "humidity" + }, + { + "text": "decibel", + "value": "dB" + }, + { + "text": "hexadecimal (0x)", + "value": "hex0x" + }, + { + "text": "hexadecimal", + "value": "hex" + }, + { + "text": "scientific notation", + "value": "sci" + }, + { + "text": "locale format", + "value": "locale" + } + ], + "text": "none" + }, + { + "submenu": [ + { + "text": "Dollars ($)", + "value": "currencyUSD" + }, + { + "text": "Pounds (£)", + "value": "currencyGBP" + }, + { + "text": "Euro (€)", + "value": "currencyEUR" + }, + { + "text": "Yen (¥)", + "value": "currencyJPY" + }, + { + "text": "Rubles (₽)", + "value": "currencyRUB" + }, + { + "text": "Hryvnias (₴)", + "value": "currencyUAH" + }, + { + "text": "Real (R$)", + "value": "currencyBRL" + }, + { + "text": "Danish Krone (kr)", + "value": "currencyDKK" + }, + { + "text": "Icelandic Króna (kr)", + "value": "currencyISK" + }, + { + "text": "Norwegian Krone (kr)", + "value": "currencyNOK" + }, + { + "text": "Swedish Krona (kr)", + "value": "currencySEK" + }, + { + "text": "Czech koruna (czk)", + "value": "currencyCZK" + }, + { + "text": "Swiss franc (CHF)", + "value": "currencyCHF" + }, + { + "text": "Polish Złoty (PLN)", + "value": "currencyPLN" + }, + { + "text": "Bitcoin (฿)", + "value": "currencyBTC" + }, + { + "text": "Vietnamese Dong (VND)", + "value": "currencyVND" + }, + { + "text": "Malaysian Ringgit (RM)", + "value": "currencyMYR" + }, + { + "text": "Bulgarian Lev (BGN)", + "value": "currencyBGN" + } + ], + "text": "currency" + }, + { + "submenu": [ + { + "text": "Hertz (1/s)", + "value": "hertz" + }, + { + "text": "nanoseconds (ns)", + "value": "ns" + }, + { + "text": "microseconds (µs)", + "value": "µs" + }, + { + "text": "milliseconds (ms)", + "value": "ms" + }, + { + "text": "seconds (s)", + "value": "s" + }, + { + "text": "minutes (m)", + "value": "m" + }, + { + "text": "hours (h)", + "value": "h" + }, + { + "text": "days (d)", + "value": "d" + }, + { + "text": "duration (ms)", + "value": "dtdurationms" + }, + { + "text": "duration (s)", + "value": "dtdurations" + }, + { + "text": "duration (hh:mm:ss)", + "value": "dthms" + }, + { + "text": "Timeticks (s/100)", + "value": "timeticks" + } + ], + "text": "time" + }, + { + "submenu": [ + { + "text": "YYYY-MM-DD HH:mm:ss", + "value": "dateTimeAsIso" + }, + { + "text": "DD/MM/YYYY h:mm:ss a", + "value": "dateTimeAsUS" + }, + { + "text": "From Now", + "value": "dateTimeFromNow" + } + ], + "text": "date \u0026 time" + }, + { + "submenu": [ + { + "text": "bits", + "value": "bits" + }, + { + "text": "bytes", + "value": "bytes" + }, + { + "text": "kibibytes", + "value": "kbytes" + }, + { + "text": "mebibytes", + "value": "mbytes" + }, + { + "text": "gibibytes", + "value": "gbytes" + } + ], + "text": "data (IEC)" + }, + { + "submenu": [ + { + "text": "bits", + "value": "decbits" + }, + { + "text": "bytes", + "value": "decbytes" + }, + { + "text": "kilobytes", + "value": "deckbytes" + }, + { + "text": "megabytes", + "value": "decmbytes" + }, + { + "text": "gigabytes", + "value": "decgbytes" + } + ], + "text": "data (Metric)" + }, + { + "submenu": [ + { + "text": "packets/sec", + "value": "pps" + }, + { + "text": "bits/sec", + "value": "bps" + }, + { + "text": "bytes/sec", + "value": "Bps" + }, + { + "text": "kilobits/sec", + "value": "Kbits" + }, + { + "text": "kilobytes/sec", + "value": "KBs" + }, + { + "text": "megabits/sec", + "value": "Mbits" + }, + { + "text": "megabytes/sec", + "value": "MBs" + }, + { + "text": "gigabytes/sec", + "value": "GBs" + }, + { + "text": "gigabits/sec", + "value": "Gbits" + } + ], + "text": "data rate" + }, + { + "submenu": [ + { + "text": "hashes/sec", + "value": "Hs" + }, + { + "text": "kilohashes/sec", + "value": "KHs" + }, + { + "text": "megahashes/sec", + "value": "MHs" + }, + { + "text": "gigahashes/sec", + "value": "GHs" + }, + { + "text": "terahashes/sec", + "value": "THs" + }, + { + "text": "petahashes/sec", + "value": "PHs" + }, + { + "text": "exahashes/sec", + "value": "EHs" + } + ], + "text": "hash rate" + }, + { + "submenu": [ + { + "text": "ops/sec (ops)", + "value": "ops" + }, + { + "text": "requests/sec (rps)", + "value": "reqps" + }, + { + "text": "reads/sec (rps)", + "value": "rps" + }, + { + "text": "writes/sec (wps)", + "value": "wps" + }, + { + "text": "I/O ops/sec (iops)", + "value": "iops" + }, + { + "text": "ops/min (opm)", + "value": "opm" + }, + { + "text": "reads/min (rpm)", + "value": "rpm" + }, + { + "text": "writes/min (wpm)", + "value": "wpm" + } + ], + "text": "throughput" + }, + { + "submenu": [ + { + "text": "millimetre (mm)", + "value": "lengthmm" + }, + { + "text": "meter (m)", + "value": "lengthm" + }, + { + "text": "inch (in)", + "value": "lengthin" + }, + { + "text": "feet (ft)", + "value": "lengthft" + }, + { + "text": "kilometer (km)", + "value": "lengthkm" + }, + { + "text": "mile (mi)", + "value": "lengthmi" + } + ], + "text": "length" + }, + { + "submenu": [ + { + "text": "Square Meters (m²)", + "value": "areaM2" + }, + { + "text": "Square Feet (ft²)", + "value": "areaF2" + }, + { + "text": "Square Miles (mi²)", + "value": "areaMI2" + } + ], + "text": "area" + }, + { + "submenu": [ + { + "text": "milligram (mg)", + "value": "massmg" + }, + { + "text": "gram (g)", + "value": "massg" + }, + { + "text": "pound (lb)", + "value": "masslb" + }, + { + "text": "kilogram (kg)", + "value": "masskg" + }, + { + "text": "metric ton (t)", + "value": "masst" + } + ], + "text": "mass" + }, + { + "submenu": [ + { + "text": "metres/second (m/s)", + "value": "velocityms" + }, + { + "text": "kilometers/hour (km/h)", + "value": "velocitykmh" + }, + { + "text": "miles/hour (mph)", + "value": "velocitymph" + }, + { + "text": "knot (kn)", + "value": "velocityknot" + } + ], + "text": "velocity" + }, + { + "submenu": [ + { + "text": "millilitre (mL)", + "value": "mlitre" + }, + { + "text": "litre (L)", + "value": "litre" + }, + { + "text": "cubic metre", + "value": "m3" + }, + { + "text": "Normal cubic metre", + "value": "Nm3" + }, + { + "text": "cubic decimetre", + "value": "dm3" + }, + { + "text": "gallons", + "value": "gallons" + } + ], + "text": "volume" + }, + { + "submenu": [ + { + "text": "Watt (W)", + "value": "watt" + }, + { + "text": "Kilowatt (kW)", + "value": "kwatt" + }, + { + "text": "Milliwatt (mW)", + "value": "mwatt" + }, + { + "text": "Watt per square metre (W/m²)", + "value": "Wm2" + }, + { + "text": "Volt-ampere (VA)", + "value": "voltamp" + }, + { + "text": "Kilovolt-ampere (kVA)", + "value": "kvoltamp" + }, + { + "text": "Volt-ampere reactive (var)", + "value": "voltampreact" + }, + { + "text": "Kilovolt-ampere reactive (kvar)", + "value": "kvoltampreact" + }, + { + "text": "Watt-hour (Wh)", + "value": "watth" + }, + { + "text": "Kilowatt-hour (kWh)", + "value": "kwatth" + }, + { + "text": "Kilowatt-min (kWm)", + "value": "kwattm" + }, + { + "text": "Joule (J)", + "value": "joule" + }, + { + "text": "Electron volt (eV)", + "value": "ev" + }, + { + "text": "Ampere (A)", + "value": "amp" + }, + { + "text": "Kiloampere (kA)", + "value": "kamp" + }, + { + "text": "Milliampere (mA)", + "value": "mamp" + }, + { + "text": "Volt (V)", + "value": "volt" + }, + { + "text": "Kilovolt (kV)", + "value": "kvolt" + }, + { + "text": "Millivolt (mV)", + "value": "mvolt" + }, + { + "text": "Decibel-milliwatt (dBm)", + "value": "dBm" + }, + { + "text": "Ohm (Ω)", + "value": "ohm" + }, + { + "text": "Lumens (Lm)", + "value": "lumens" + } + ], + "text": "energy" + }, + { + "submenu": [ + { + "text": "Celsius (°C)", + "value": "celsius" + }, + { + "text": "Fahrenheit (°F)", + "value": "fahrenheit" + }, + { + "text": "Kelvin (K)", + "value": "kelvin" + } + ], + "text": "temperature" + }, + { + "submenu": [ + { + "text": "Millibars", + "value": "pressurembar" + }, + { + "text": "Bars", + "value": "pressurebar" + }, + { + "text": "Kilobars", + "value": "pressurekbar" + }, + { + "text": "Hectopascals", + "value": "pressurehpa" + }, + { + "text": "Kilopascals", + "value": "pressurekpa" + }, + { + "text": "Inches of mercury", + "value": "pressurehg" + }, + { + "text": "PSI", + "value": "pressurepsi" + } + ], + "text": "pressure" + }, + { + "submenu": [ + { + "text": "Newton-meters (Nm)", + "value": "forceNm" + }, + { + "text": "Kilonewton-meters (kNm)", + "value": "forcekNm" + }, + { + "text": "Newtons (N)", + "value": "forceN" + }, + { + "text": "Kilonewtons (kN)", + "value": "forcekN" + } + ], + "text": "force" + }, + { + "submenu": [ + { + "text": "Gallons/min (gpm)", + "value": "flowgpm" + }, + { + "text": "Cubic meters/sec (cms)", + "value": "flowcms" + }, + { + "text": "Cubic feet/sec (cfs)", + "value": "flowcfs" + }, + { + "text": "Cubic feet/min (cfm)", + "value": "flowcfm" + }, + { + "text": "Litre/hour", + "value": "litreh" + }, + { + "text": "Litre/min (l/min)", + "value": "flowlpm" + }, + { + "text": "milliLitre/min (mL/min)", + "value": "flowmlpm" + } + ], + "text": "flow" + }, + { + "submenu": [ + { + "text": "Degrees (°)", + "value": "degree" + }, + { + "text": "Radians", + "value": "radian" + }, + { + "text": "Gradian", + "value": "grad" + } + ], + "text": "angle" + }, + { + "submenu": [ + { + "text": "Meters/sec²", + "value": "accMS2" + }, + { + "text": "Feet/sec²", + "value": "accFS2" + }, + { + "text": "G unit", + "value": "accG" + } + ], + "text": "acceleration" + }, + { + "submenu": [ + { + "text": "Becquerel (Bq)", + "value": "radbq" + }, + { + "text": "curie (Ci)", + "value": "radci" + }, + { + "text": "Gray (Gy)", + "value": "radgy" + }, + { + "text": "rad", + "value": "radrad" + }, + { + "text": "Sievert (Sv)", + "value": "radsv" + }, + { + "text": "rem", + "value": "radrem" + }, + { + "text": "Exposure (C/kg)", + "value": "radexpckg" + }, + { + "text": "roentgen (R)", + "value": "radr" + }, + { + "text": "Sievert/hour (Sv/h)", + "value": "radsvh" + } + ], + "text": "radiation" + }, + { + "submenu": [ + { + "text": "parts-per-million (ppm)", + "value": "ppm" + }, + { + "text": "parts-per-billion (ppb)", + "value": "conppb" + }, + { + "text": "nanogram per cubic metre (ng/m³)", + "value": "conngm3" + }, + { + "text": "nanogram per normal cubic metre (ng/Nm³)", + "value": "conngNm3" + }, + { + "text": "microgram per cubic metre (μg/m³)", + "value": "conμgm3" + }, + { + "text": "microgram per normal cubic metre (μg/Nm³)", + "value": "conμgNm3" + }, + { + "text": "milligram per cubic metre (mg/m³)", + "value": "conmgm3" + }, + { + "text": "milligram per normal cubic metre (mg/Nm³)", + "value": "conmgNm3" + }, + { + "text": "gram per cubic metre (g/m³)", + "value": "congm3" + }, + { + "text": "gram per normal cubic metre (g/Nm³)", + "value": "congNm3" + } + ], + "text": "concentration" + } + ] + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/v0alpha1.v1beta1.v1beta1.all-panels.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/v0alpha1.v1beta1.v1beta1.all-panels.v42.v2alpha1.json index 315164a75a3..43ca604b064 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/v0alpha1.v1beta1.v1beta1.all-panels.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/v0alpha1.v1beta1.v1beta1.all-panels.v42.v2alpha1.json @@ -665,7 +665,42 @@ "kind": "heatmap", "spec": { "pluginVersion": "", - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "heatmap", + "originalOptions": { + "cards": {}, + "color": { + "cardColor": "#b4ff00", + "colorScale": "sqrt", + "colorScheme": "interpolateOranges", + "exponent": 0.5, + "mode": "spectrum" + }, + "dataFormat": "timeseries", + "heatmap": {}, + "hideZeroBuckets": false, + "highlightCards": true, + "legend": { + "show": false + }, + "reverseYBuckets": false, + "tooltip": { + "show": true, + "showHistogram": false + }, + "xAxis": { + "show": true + }, + "yAxis": { + "format": "short", + "logBase": 1, + "show": true + }, + "yBucketBound": "auto" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/v0alpha1.v1beta1.v1beta1.all-panels.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/v0alpha1.v1beta1.v1beta1.all-panels.v42.v2beta1.json index 94f898bfa16..ae693615b73 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/v0alpha1.v1beta1.v1beta1.all-panels.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/v0alpha1.v1beta1.v1beta1.all-panels.v42.v2beta1.json @@ -691,7 +691,42 @@ "group": "heatmap", "version": "", "spec": { - "options": {}, + "options": { + "__angularMigration": { + "autoMigrateFrom": "heatmap", + "originalOptions": { + "cards": {}, + "color": { + "cardColor": "#b4ff00", + "colorScale": "sqrt", + "colorScheme": "interpolateOranges", + "exponent": 0.5, + "mode": "spectrum" + }, + "dataFormat": "timeseries", + "heatmap": {}, + "hideZeroBuckets": false, + "highlightCards": true, + "legend": { + "show": false + }, + "reverseYBuckets": false, + "tooltip": { + "show": true, + "showHistogram": false + }, + "xAxis": { + "show": true + }, + "yAxis": { + "format": "short", + "logBase": 1, + "show": true + }, + "yBucketBound": "auto" + } + } + }, "fieldConfig": { "defaults": {}, "overrides": [] diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/v0alpha1.v1beta1.v1beta1.home.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/v0alpha1.v1beta1.v1beta1.home.v42.v2alpha1.json index 2a6500f86db..7c77141f891 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/v0alpha1.v1beta1.v1beta1.home.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/v0alpha1.v1beta1.v1beta1.home.v42.v2alpha1.json @@ -56,6 +56,14 @@ "spec": { "pluginVersion": "9.0.0-pre", "options": { + "__angularMigration": { + "autoMigrateFrom": "dashlist", + "originalOptions": { + "tags": [ + "panel-tests" + ] + } + }, "maxItems": 1000, "query": "", "showHeadings": false, @@ -94,6 +102,15 @@ "spec": { "pluginVersion": "9.0.0-pre", "options": { + "__angularMigration": { + "autoMigrateFrom": "dashlist", + "originalOptions": { + "tags": [ + "gdev", + "demo" + ] + } + }, "maxItems": 1000, "query": "", "showHeadings": false, @@ -133,6 +150,15 @@ "spec": { "pluginVersion": "9.0.0-pre", "options": { + "__angularMigration": { + "autoMigrateFrom": "dashlist", + "originalOptions": { + "tags": [ + "templating", + "gdev" + ] + } + }, "maxItems": 1000, "query": "", "showHeadings": false, @@ -172,6 +198,15 @@ "spec": { "pluginVersion": "9.0.0-pre", "options": { + "__angularMigration": { + "autoMigrateFrom": "dashlist", + "originalOptions": { + "tags": [ + "gdev", + "datasource-test" + ] + } + }, "maxItems": 1000, "query": "", "showHeadings": false, @@ -211,6 +246,12 @@ "spec": { "pluginVersion": "9.0.0-pre", "options": { + "__angularMigration": { + "autoMigrateFrom": "dashlist", + "originalOptions": { + "tags": [] + } + }, "maxItems": 100, "query": "", "showHeadings": true, @@ -247,6 +288,15 @@ "spec": { "pluginVersion": "9.0.0-pre", "options": { + "__angularMigration": { + "autoMigrateFrom": "dashlist", + "originalOptions": { + "tags": [ + "gdev", + "demo" + ] + } + }, "maxItems": 1000, "query": "", "showHeadings": false, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/v0alpha1.v1beta1.v1beta1.home.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/v0alpha1.v1beta1.v1beta1.home.v42.v2beta1.json index 0580a517cb2..5cec6a9741b 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/v0alpha1.v1beta1.v1beta1.home.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/v0alpha1.v1beta1.v1beta1.home.v42.v2beta1.json @@ -58,6 +58,14 @@ "version": "9.0.0-pre", "spec": { "options": { + "__angularMigration": { + "autoMigrateFrom": "dashlist", + "originalOptions": { + "tags": [ + "panel-tests" + ] + } + }, "maxItems": 1000, "query": "", "showHeadings": false, @@ -97,6 +105,15 @@ "version": "9.0.0-pre", "spec": { "options": { + "__angularMigration": { + "autoMigrateFrom": "dashlist", + "originalOptions": { + "tags": [ + "gdev", + "demo" + ] + } + }, "maxItems": 1000, "query": "", "showHeadings": false, @@ -137,6 +154,15 @@ "version": "9.0.0-pre", "spec": { "options": { + "__angularMigration": { + "autoMigrateFrom": "dashlist", + "originalOptions": { + "tags": [ + "templating", + "gdev" + ] + } + }, "maxItems": 1000, "query": "", "showHeadings": false, @@ -177,6 +203,15 @@ "version": "9.0.0-pre", "spec": { "options": { + "__angularMigration": { + "autoMigrateFrom": "dashlist", + "originalOptions": { + "tags": [ + "gdev", + "datasource-test" + ] + } + }, "maxItems": 1000, "query": "", "showHeadings": false, @@ -217,6 +252,12 @@ "version": "9.0.0-pre", "spec": { "options": { + "__angularMigration": { + "autoMigrateFrom": "dashlist", + "originalOptions": { + "tags": [] + } + }, "maxItems": 100, "query": "", "showHeadings": true, @@ -254,6 +295,15 @@ "version": "9.0.0-pre", "spec": { "options": { + "__angularMigration": { + "autoMigrateFrom": "dashlist", + "originalOptions": { + "tags": [ + "gdev", + "demo" + ] + } + }, "maxItems": 1000, "query": "", "showHeadings": false, diff --git a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go index db86f8c87a9..47c12a6ac94 100644 --- a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go +++ b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go @@ -2296,20 +2296,24 @@ func buildVizConfig(panelMap map[string]interface{}) dashv2alpha1.DashboardVizCo // We check two cases: // 1. Panel already has autoMigrateFrom set (from v0→v1 migration) - panel type already converted // 2. Panel type is a known Angular panel - need to convert type AND set autoMigrateFrom + // 3. Panel has original options - need to set autoMigrateFrom and originalOptions autoMigrateFrom, hasAutoMigrateFrom := panelMap["autoMigrateFrom"].(string) + originalOptions := extractAngularOptions(panelMap) if !hasAutoMigrateFrom || autoMigrateFrom == "" { // Check if panel type is an Angular type that needs migration if newType := getAngularPanelMigration(panelType, panelMap); newType != "" { autoMigrateFrom = panelType // Original Angular type panelType = newType // New modern type + } else if len(originalOptions) > 0 { + autoMigrateFrom = panelType } } if autoMigrateFrom != "" { options["__angularMigration"] = map[string]interface{}{ "autoMigrateFrom": autoMigrateFrom, - "originalOptions": extractAngularOptions(panelMap), + "originalOptions": originalOptions, } } diff --git a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/migrations/migrations.v42.json b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/migrations/migrations.v42.json index 89ce80876d2..6ff361f144e 100644 --- a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/migrations/migrations.v42.json +++ b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/migrations/migrations.v42.json @@ -290,6 +290,7 @@ } }, { + "content": "# Graph panel \u003e\u003e Timeseries panel\n\nKnown issues:\n* hiding null/empty series\n* time regions", "datasource": { "type": "grafana-testdata-datasource" }, @@ -300,15 +301,7 @@ "y": 0 }, "id": 6, - "options": { - "code": { - "language": "plaintext", - "showLineNumbers": false, - "showMiniMap": false - }, - "content": "# Graph panel \u003e\u003e Timeseries panel\n\nKnown issues:\n* hiding null/empty series\n* time regions", - "mode": "markdown" - }, + "mode": "markdown", "pluginVersion": "11.0.0-pre", "targets": [ { diff --git a/devenv/dev-dashboards/migrations/migrations.json b/devenv/dev-dashboards/migrations/migrations.json index 4c2160e1f26..ad063b99cf4 100644 --- a/devenv/dev-dashboards/migrations/migrations.json +++ b/devenv/dev-dashboards/migrations/migrations.json @@ -299,15 +299,9 @@ "y": 0 }, "id": 6, - "options": { - "code": { - "language": "plaintext", - "showLineNumbers": false, - "showMiniMap": false - }, - "content": "# Graph panel >> Timeseries panel\n\nKnown issues:\n* hiding null/empty series\n* time regions", - "mode": "markdown" - }, + "options": {}, + "content": "# Graph panel >> Timeseries panel\n\nKnown issues:\n* hiding null/empty series\n* time regions", + "mode": "markdown", "pluginVersion": "11.0.0-pre", "targets": [ { diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 08f37b316d6..80d18ce0cae 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1888,11 +1888,6 @@ "count": 1 } }, - "public/app/features/dashboard-scene/serialization/angularMigration.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, "public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.ts": { "@typescript-eslint/consistent-type-assertions": { "count": 1 diff --git a/public/app/features/dashboard-scene/serialization/angularMigration.test.ts b/public/app/features/dashboard-scene/serialization/angularMigration.test.ts index 46c67f6cd9f..2eea13b90d5 100644 --- a/public/app/features/dashboard-scene/serialization/angularMigration.test.ts +++ b/public/app/features/dashboard-scene/serialization/angularMigration.test.ts @@ -1,9 +1,22 @@ -import { PanelTypeChangedHandler } from '@grafana/data'; +import { FieldConfigSource, PanelTypeChangedHandler } from '@grafana/data'; import { getPanelPlugin } from '@grafana/data/test'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; import { getV2AngularMigrationHandler, getAngularPanelMigrationHandler } from './angularMigration'; +/** + * Test type for mutable panel model used in migration handler tests. + * Allows arbitrary properties to be added during migration. + */ +interface TestPanelModel { + id: number; + type: string; + options: Record; + fieldConfig: FieldConfigSource; + targets?: Array<{ refId: string; [key: string]: unknown }>; + [key: string]: unknown; +} + describe('getAngularPanelMigrationHandler', () => { describe('Given an old angular panel', () => { it('Should call migration handler', () => { @@ -43,9 +56,11 @@ describe('getAngularPanelMigrationHandler', () => { type: 'dashlist', }); - const mutatedModel: any = { + const mutatedModel: TestPanelModel = { + id: 1, type: 'dashlist', options: {}, + fieldConfig: { defaults: {}, overrides: [] }, }; getAngularPanelMigrationHandler(oldModel)(mutatedModel, reactPlugin); @@ -80,7 +95,7 @@ describe('getV2AngularMigrationHandler', () => { }, }; - const mutatedModel = { + const mutatedModel: TestPanelModel = { id: 1, type: 'stat', options: {}, @@ -89,6 +104,11 @@ describe('getV2AngularMigrationHandler', () => { getV2AngularMigrationHandler(migrationData)(mutatedModel, reactPlugin); + // Verify originalOptions were spread onto the panel (for plugins using setMigrationHandler) + expect(mutatedModel['format']).toBe('short'); + expect(mutatedModel['valueName']).toBe('avg'); + expect(mutatedModel['orientation']).toBe('horizontal'); + // Verify handler received correct arguments expect(receivedPrevPluginId).toBe('singlestat'); expect(receivedPrevOptions?.angular).toBeDefined(); @@ -123,7 +143,7 @@ describe('getV2AngularMigrationHandler', () => { }, }; - const mutatedModel = { + const mutatedModel: TestPanelModel = { id: 1, type: 'timeseries', options: {}, @@ -132,6 +152,10 @@ describe('getV2AngularMigrationHandler', () => { getV2AngularMigrationHandler(migrationData)(mutatedModel, reactPlugin); + // Verify originalOptions were spread onto the panel (for plugins using setMigrationHandler) + expect(mutatedModel['bars']).toBe(true); + expect(mutatedModel['lines']).toBe(false); + // Verify handler received correct arguments expect(receivedPrevPluginId).toBe('graph'); expect(receivedPrevOptions?.angular).toBeDefined(); @@ -163,7 +187,7 @@ describe('getV2AngularMigrationHandler', () => { }, }; - const mutatedModel = { + const mutatedModel: TestPanelModel = { id: 1, type: 'new-panel', options: {}, @@ -172,6 +196,9 @@ describe('getV2AngularMigrationHandler', () => { getV2AngularMigrationHandler(migrationData)(mutatedModel, reactPlugin); + // Verify originalOptions were spread onto the panel (for plugins using setMigrationHandler) + expect(mutatedModel['oldOption']).toBe('old'); + // Verify handler received correct arguments (options wrapper, not angular) expect(receivedPrevPluginId).toBe('some-react-panel'); expect(receivedPrevOptions?.options).toBeDefined(); @@ -222,4 +249,118 @@ describe('getV2AngularMigrationHandler', () => { warnSpy.mockRestore(); }); }); + + describe('Given v2 migration data for text panel with Angular-style properties', () => { + it('Should spread originalOptions onto panel for migration handlers using setMigrationHandler', () => { + // Text panel uses setMigrationHandler, not onPanelTypeChanged + // The migration handler expects content/mode to be directly on the panel object + const reactPlugin = getPanelPlugin({ id: 'text' }); + + // This simulates a text panel with Angular-style properties at root level + // The backend sets autoMigrateFrom="text" when it detects these properties + const migrationData = { + autoMigrateFrom: 'text', + originalOptions: { + content: 'Hello World', + mode: 'markdown', + }, + }; + + const mutatedModel: TestPanelModel = { + id: 1, + type: 'text', + options: {}, + fieldConfig: { defaults: {}, overrides: [] }, + }; + + getV2AngularMigrationHandler(migrationData)(mutatedModel, reactPlugin); + + // Verify originalOptions were spread onto the panel + // This allows textPanelMigrationHandler to see content/mode via panel.hasOwnProperty() + expect(mutatedModel['content']).toBe('Hello World'); + expect(mutatedModel['mode']).toBe('markdown'); + }); + + it('Should not overwrite existing panel properties when spreading originalOptions', () => { + const reactPlugin = getPanelPlugin({ id: 'text' }); + + const migrationData = { + autoMigrateFrom: 'text', + originalOptions: { + content: 'Old content', + mode: 'markdown', + id: 999, // Should not overwrite existing id + }, + }; + + const mutatedModel: TestPanelModel = { + id: 1, + type: 'text', + options: { existingOption: true }, + fieldConfig: { defaults: {}, overrides: [] }, + }; + + getV2AngularMigrationHandler(migrationData)(mutatedModel, reactPlugin); + + // defaults() only sets properties that don't already exist + expect(mutatedModel['content']).toBe('Old content'); + expect(mutatedModel['mode']).toBe('markdown'); + expect(mutatedModel.id).toBe(1); // Should keep original id + expect(mutatedModel.options).toEqual({ existingOption: true }); // Should keep existing options + }); + + it('Should work with empty originalOptions', () => { + const reactPlugin = getPanelPlugin({ id: 'text' }); + + const migrationData = { + autoMigrateFrom: 'text', + originalOptions: {}, + }; + + const mutatedModel: TestPanelModel = { + id: 1, + type: 'text', + options: {}, + fieldConfig: { defaults: {}, overrides: [] }, + }; + + // Should not throw + expect(() => { + getV2AngularMigrationHandler(migrationData)(mutatedModel, reactPlugin); + }).not.toThrow(); + }); + + it('Should spread originalOptions AND call onPanelTypeChanged if plugin has both', () => { + let handlerCalled = false; + const onPanelTypeChanged: PanelTypeChangedHandler = (panel, prevPluginId, prevOptions) => { + handlerCalled = true; + // Verify originalOptions were already spread onto panel before handler is called + expect((panel as TestPanelModel)['customProp']).toBe('custom value'); + return { migrated: true }; + }; + + const reactPlugin = getPanelPlugin({ id: 'custom-panel' }).setPanelChangeHandler(onPanelTypeChanged); + + const migrationData = { + autoMigrateFrom: 'custom-panel', + originalOptions: { + customProp: 'custom value', + }, + }; + + const mutatedModel: TestPanelModel = { + id: 1, + type: 'custom-panel', + options: {}, + fieldConfig: { defaults: {}, overrides: [] }, + }; + + getV2AngularMigrationHandler(migrationData)(mutatedModel, reactPlugin); + + // Verify both behaviors occurred + expect(mutatedModel['customProp']).toBe('custom value'); // originalOptions spread + expect(handlerCalled).toBe(true); // onPanelTypeChanged called + expect(mutatedModel.options).toEqual({ migrated: true }); // handler result applied + }); + }); }); diff --git a/public/app/features/dashboard-scene/serialization/angularMigration.ts b/public/app/features/dashboard-scene/serialization/angularMigration.ts index 54b47081080..c0751c802d9 100644 --- a/public/app/features/dashboard-scene/serialization/angularMigration.ts +++ b/public/app/features/dashboard-scene/serialization/angularMigration.ts @@ -77,6 +77,11 @@ export function getAngularPanelMigrationHandler(oldModel: PanelModel) { * 4. Handler calls stat plugin's onPanelTypeChanged with { angular: originalOptions } * 5. Plugin migrates format/valueName/etc to proper stat options * + * For panels where autoMigrateFrom equals the current type (e.g., "text" -> "text"): + * - These are panels with Angular-style properties at the root level (content, mode, etc.) + * - We spread originalOptions onto the panel so the plugin's migration handler can see them + * - This matches the v1 behavior where PanelModel.restoreModel() spreads all properties + * * @param migrationData The __angularMigration data extracted from panel options */ export function getV2AngularMigrationHandler(migrationData: AngularMigrationData) { @@ -90,19 +95,28 @@ export function getV2AngularMigrationHandler(migrationData: AngularMigrationData return; } - if (plugin.onPanelTypeChanged) { - // Some plugins rely on being able to access targets to set up the fieldConfig when migrating from angular. - // Proxy the targets property with a deprecation warning. - const targetClone = cloneDeep(panel.targets); - Object.defineProperty(panel, 'targets', { - get: function () { - console.warn( - 'Accessing the targets property when migrating a panel plugin is deprecated. Changes to this property will be ignored.' - ); - return targetClone; - }, - }); + // Spread originalOptions onto the panel object. + // This is critical for plugins that use setMigrationHandler (like text panel) which expect + // Angular properties (content, mode, etc.) to be directly on the panel object. + // This matches the v1 behavior where PanelModel.restoreModel() spreads all JSON properties. + if (originalOptions && Object.keys(originalOptions).length > 0) { + defaults(panel, originalOptions); + } + // Some plugins rely on being able to access targets to set up the fieldConfig when migrating from angular. + // Proxy the targets property with a deprecation warning. + const targetClone = cloneDeep(panel.targets); + Object.defineProperty(panel, 'targets', { + get: function () { + console.warn( + 'Accessing the targets property when migrating a panel plugin is deprecated. Changes to this property will be ignored.' + ); + return targetClone; + }, + }); + + // For panels with onPanelTypeChanged (e.g., singlestat -> stat), call the handler + if (plugin.onPanelTypeChanged) { // For Angular panels, wrap in { angular: ... } to match expected format // For React panels migrating from other React panels, pass options directly const prevOptions = wasAngular ? { angular: originalOptions } : { options: originalOptions }; diff --git a/public/app/features/dashboard/api/ResponseTransformers.ts b/public/app/features/dashboard/api/ResponseTransformers.ts index 5a536074efe..9d68b84e2e4 100644 --- a/public/app/features/dashboard/api/ResponseTransformers.ts +++ b/public/app/features/dashboard/api/ResponseTransformers.ts @@ -609,17 +609,25 @@ export function buildPanelKind(p: Panel): PanelKind { // Build options with Angular migration data if needed (matches backend behavior) // autoMigrateFrom is set during v0->v1 migration when Angular panels are converted - const { autoMigrateFrom } = p; + let { autoMigrateFrom } = p; let options = p.options ?? {}; + const originalOptions = extractAngularOptions(p); + + // When autoMigrateFrom is present OR when there are Angular-specific properties at root level, + // compose __angularMigration with only Angular-specific options. + // This filters out known Panel schema properties, passing only the Angular options to migration handlers. + // The second condition handles panels like "text" that have Angular-style properties (content, mode) + // but don't have autoMigrateFrom set because they don't need a type conversion. + if (!autoMigrateFrom && Object.keys(originalOptions).length > 0) { + autoMigrateFrom = p.type; + } - // When autoMigrateFrom is present, compose __angularMigration with only Angular-specific options - // This filters out known Panel schema properties, passing only the Angular options to migration handlers if (autoMigrateFrom) { options = { ...options, __angularMigration: { autoMigrateFrom, - originalOptions: extractAngularOptions(p), + originalOptions, }, }; } From 58a026b6a5bc0e869430c05829756e868ffe4d1b Mon Sep 17 00:00:00 2001 From: Yunwen Zheng Date: Thu, 18 Dec 2025 10:22:40 -0500 Subject: [PATCH 023/229] RecentlyViewedDashboards: Clear history button (#115519) * RecentlyViewedDashboards: Clear history button --- .../components/RecentlyViewedDashboards.tsx | 45 ++++++++++++++----- public/app/plugins/panel/dashlist/styles.ts | 2 +- public/locales/en-US/grafana.json | 1 + 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/public/app/features/browse-dashboards/components/RecentlyViewedDashboards.tsx b/public/app/features/browse-dashboards/components/RecentlyViewedDashboards.tsx index ebfbd1d0899..7b2d58fa423 100644 --- a/public/app/features/browse-dashboards/components/RecentlyViewedDashboards.tsx +++ b/public/app/features/browse-dashboards/components/RecentlyViewedDashboards.tsx @@ -1,10 +1,12 @@ import { css } from '@emotion/css'; -import { useAsync } from 'react-use'; +import { useState } from 'react'; +import { useAsyncRetry } from 'react-use'; -import { GrafanaTheme2 } from '@grafana/data'; +import { GrafanaTheme2, store } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; import { evaluateBooleanFlag } from '@grafana/runtime/internal'; -import { CollapsableSection, Grid, Spinner, Text, useStyles2 } from '@grafana/ui'; +import { Button, CollapsableSection, Spinner, Stack, Text, useStyles2, Grid } from '@grafana/ui'; +import { contextSrv } from 'app/core/services/context_srv'; import { useDashboardLocationInfo } from 'app/features/search/hooks/useDashboardLocationInfo'; import { DashListItem } from 'app/plugins/panel/dashlist/DashListItem'; @@ -12,10 +14,18 @@ import { getRecentlyViewedDashboards } from './utils'; const MAX_RECENT = 5; +const recentDashboardsKey = `dashboard_impressions-${contextSrv.user.orgId}`; + export function RecentlyViewedDashboards() { + const [isOpen, setIsOpen] = useState(true); + const styles = useStyles2(getStyles); - const { value: recentDashboards = [], loading } = useAsync(async () => { + const { + value: recentDashboards = [], + loading, + retry, + } = useAsyncRetry(async () => { if (!evaluateBooleanFlag('recentlyViewedDashboards', false)) { return []; } @@ -23,6 +33,11 @@ export function RecentlyViewedDashboards() { }, []); const { foldersByUid } = useDashboardLocationInfo(recentDashboards.length > 0); + const handleClearHistory = () => { + store.set(recentDashboardsKey, JSON.stringify([])); + retry(); + }; + if (!evaluateBooleanFlag('recentlyViewedDashboards', false)) { return null; } @@ -31,11 +46,19 @@ export function RecentlyViewedDashboards() { - Recently viewed - + + setIsOpen(!isOpen)}> + Recently viewed + + + } - isOpen={true} + isOpen={isOpen} + // passing empty function to disable controlled mode, we only want to control isOpen when click on title + // this avoid entire header section being clickable which can be confusing with the Clear history button + onToggle={() => {}} className={styles.title} contentClassName={styles.content} > @@ -71,14 +94,16 @@ export function RecentlyViewedDashboards() { const getStyles = (theme: GrafanaTheme2) => { return { title: css({ - '& button svg': { + cursor: 'default', + '& [id^="collapse-button-"] svg': { color: theme.colors.primary.text, }, h3: { - background: `linear-gradient(90deg, ${theme.colors.primary.text} 0%, ${theme.colors.secondary.text} 100%)`, + background: `linear-gradient(90deg, ${theme.colors.primary.shade} 0%, ${theme.colors.primary.text} 100%)`, WebkitTextFillColor: 'transparent', backgroundClip: 'text', color: 'transparent', + cursor: 'pointer', }, }), content: css({ diff --git a/public/app/plugins/panel/dashlist/styles.ts b/public/app/plugins/panel/dashlist/styles.ts index 5e725daaf2d..c6346480c22 100644 --- a/public/app/plugins/panel/dashlist/styles.ts +++ b/public/app/plugins/panel/dashlist/styles.ts @@ -6,7 +6,7 @@ export const getStyles = (theme: GrafanaTheme2) => { const gradient = `linear-gradient( 90deg, ${colorManipulator.alpha(theme.colors.primary.text, 0.1)} 0%, - ${colorManipulator.alpha(theme.colors.secondary.text, 0.1)} 100% + ${colorManipulator.alpha(theme.colors.secondary.main, 0.1)} 100% )`; return { dashlistLink: css({ diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 25ad73abe1a..2e86a2a42d3 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -3717,6 +3717,7 @@ "text": "No results found for your query" }, "recently-viewed": { + "clear": "Clear history", "empty": "Nothing viewed yet", "title": "Recently viewed" }, From a65aa9d18fa5858359c09d2efba5b6e58935247a Mon Sep 17 00:00:00 2001 From: Vardan Torosyan Date: Thu, 18 Dec 2025 16:26:56 +0100 Subject: [PATCH 024/229] SCIM Docs: Replace warning with an information text for SAML identifier (#115353) * SCIM Docs: Replace warning with an information text for SAML identifier * Fix externalId warning --- .../configure-scim-provisioning/_index.md | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/docs/sources/setup-grafana/configure-access/configure-scim-provisioning/_index.md b/docs/sources/setup-grafana/configure-access/configure-scim-provisioning/_index.md index 9ef98bbc433..a07dde69e08 100644 --- a/docs/sources/setup-grafana/configure-access/configure-scim-provisioning/_index.md +++ b/docs/sources/setup-grafana/configure-access/configure-scim-provisioning/_index.md @@ -54,18 +54,6 @@ SCIM offers several advantages for managing users and teams in Grafana: ## Authentication and access requirements -{{< admonition type="warning" title="Critical: Aligning SAML Identifier with SCIM externalId" >}} -When using SAML for authentication alongside SCIM provisioning, a critical security measure is to ensure proper alignment between the the SCIM user's `externalId` and the SAML user identifier. The unique identifier used for SCIM provisioning (which becomes the `externalId` in Grafana, often sourced from a stable IdP attribute like Entra ID's `user.objectid`) **must also be sent as a claim in the SAML assertion from your Identity Provider.** -Furthermore, the Grafana SAML configuration must be correctly set up to identify and use this specific claim for linking the authenticated SAML user to their SCIM-provisioned user. This can be achieved by either ensuring the primary SAML login identifier by using the `assertion_attribute_external_uid` setting in Grafana to explicitly set the name of the SAML claim that contains the stable unique identifier attribute. - -**Why is this important?** -A mismatch or inconsistent mapping between this SAML login identifier and the SCIM `externalId` creates a critical security vulnerability. If these two identifiers are not reliably and uniquely aligned for each individual user, Grafana may fail to correctly link an authenticated SAML session to the intended SCIM-provisioned user profile and its associated permissions. This can enable a malicious actor to impersonate another user—for instance, by crafting a SAML assertion that, due to the identifier misalignment, incorrectly grants them the access rights of the targeted user. - -Grafana relies on this linkage to correctly associate the authenticated user from SAML with the provisioned user from SCIM. Failure to ensure a consistent and unique identifier across both systems can break this linkage, leading to incorrect user mapping and potential unauthorized access. - -Always verify that your SAML identity provider is configured to send a stable, unique user identifier that your SCIM configuration maps to `externalId`. Refer to your identity provider's documentation and the specific Grafana SCIM integration guides (e.g., for [Entra ID](configure-scim-with-azuread/) or [Okta](configure-scim-with-okta/)) for detailed instructions on configuring these attributes correctly. -{{< /admonition >}} - When you enable SCIM in Grafana, the following requirements and restrictions apply: 1. **Use the same identity provider for user provisioning and for authentication flow**: You must use the same identity provider for both authentication and user provisioning. @@ -74,6 +62,12 @@ When you enable SCIM in Grafana, the following requirements and restrictions app - Configure `userUID` SAML assertion in [Entra ID](/docs/grafana//setup-grafana/configure-access/configure-authentication/saml/configure-saml-with-azuread/#configure-saml-assertions-when-using-scim-provisioning) - Configure `userUID` SAML assertion in [Okta](/docs/grafana//setup-grafana/configure-access/configure-authentication/saml/configure-saml-with-okta/#configure-saml-assertions-when-using-scim-provisioning) +### Align SAML identifier with SCIM `externalId` + +When you use SAML with SCIM provisioning, align the SCIM `externalId` with the SAML user identifier. Use a stable IdP attribute (for example, Entra ID `user.objectid`) as the SCIM `externalId`, and send that same value as a SAML claim. Configure Grafana to read this claim with the `assertion_attribute_external_uid` setting so SAML authentication links to the SCIM-provisioned user and its permissions. + +If the SAML identifier and SCIM `externalId` differ, Grafana may not link the authenticated user to the intended SCIM profile, which can result in incorrect access. Verify your IdP sends a stable, unique identifier and that it matches the SCIM `externalId`. Refer to your IdP docs and the Grafana SCIM integration guides for [Entra ID](configure-scim-with-azuread/) and [Okta](configure-scim-with-okta/) for attribute configuration details. + ## Configure SCIM using the Grafana user interface You can configure SCIM in Grafana using the Grafana user interface. To do this, navigate to **Administration > Authentication > SCIM**. From 98aa6c50dc6ef8979c2f8e5e9f4fee4a944a40f1 Mon Sep 17 00:00:00 2001 From: Alexa Vargas <239999+axelavargas@users.noreply.github.com> Date: Thu, 18 Dec 2025 16:42:37 +0100 Subject: [PATCH 025/229] DashboardLibrary: Force v1 dashboard scene page manager when loading template dashboards (#115488) Force v1 manager for template dashboards feature --- .../DashboardScenePageStateManager.test.ts | 117 ++++++++++++++++++ .../pages/DashboardScenePageStateManager.ts | 6 + 2 files changed, 123 insertions(+) diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts index 00de1c7ea13..b1857c9f01e 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts @@ -1673,6 +1673,123 @@ describe('UnifiedDashboardScenePageStateManager', () => { expect(manager2['activeManager']).toBeInstanceOf(DashboardScenePageStateManagerV2); }); }); + + describe('Template dashboards', () => { + let originalGetLocation: typeof locationService.getLocation; + + beforeEach(() => { + originalGetLocation = locationService.getLocation; + + // Mock window.location.search for loadDashboardLibrary + Object.defineProperty(window, 'location', { + value: { + search: '?gnetId=969&datasource=xpyRJd9Mz&mappings=%5B%5D', + }, + writable: true, + }); + }); + + afterEach(() => { + locationService.getLocation = originalGetLocation; + }); + + it('should always use v1 manager for template dashboards even when dashboardNewLayouts is enabled', async () => { + config.featureToggles.dashboardNewLayouts = true; + config.featureToggles.suggestedDashboards = true; + + // Mock location service with gnetId and mappings parameters + locationService.getLocation = jest.fn().mockReturnValue({ + pathname: '/dashboard/template', + search: '?gnetId=969&datasource=xpyRJd9Mz&mappings=%5B%5D', + }); + + // Mock the backend to return a community dashboard from grafana.com + setBackendSrv({ + get: jest.fn((url: string) => { + if (url.includes('/api/gnet/dashboards/')) { + return Promise.resolve({ + json: { + title: 'AWS ElastiCache Redis', + uid: '', + panels: [], + schemaVersion: 40, + }, + }); + } + return Promise.reject(new Error('Not found')); + }), + post: jest.fn((url: string) => { + if (url === '/api/dashboards/interpolate') { + return Promise.resolve({ + title: 'AWS ElastiCache Redis', + uid: '', + panels: [], + schemaVersion: 40, + }); + } + return Promise.reject(new Error('Not found')); + }), + } as unknown as BackendSrv); + + const manager = new UnifiedDashboardScenePageStateManager({}); + expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManagerV2); + + await manager.loadDashboard({ uid: '', route: DashboardRoutes.Template }); + + // Should switch to V1 manager for template dashboards + expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManager); + }); + + it('should reset to V2 manager when loading a new dashboard after template', async () => { + config.featureToggles.dashboardNewLayouts = true; + config.featureToggles.suggestedDashboards = true; + + // Mock locationService for this test too + locationService.getLocation = jest.fn().mockReturnValue({ + pathname: '/dashboard/template', + search: '?gnetId=969&datasource=xpyRJd9Mz&mappings=%5B%5D', + }); + + // Mock the backend for template load + setBackendSrv({ + get: jest.fn((url: string) => { + if (url.includes('/api/gnet/dashboards/')) { + return Promise.resolve({ + json: { + title: 'Template Dashboard', + uid: '', + panels: [], + schemaVersion: 40, + }, + }); + } + return Promise.reject(new Error('Not found')); + }), + post: jest.fn((url: string) => { + if (url === '/api/dashboards/interpolate') { + return Promise.resolve({ + title: 'Template Dashboard', + uid: '', + panels: [], + schemaVersion: 40, + }); + } + return Promise.reject(new Error('Not found')); + }), + } as unknown as BackendSrv); + + const manager = new UnifiedDashboardScenePageStateManager({}); + + // Load template - forces V1 + await manager.loadDashboard({ uid: '', route: DashboardRoutes.Template }); + expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManager); + + // Load new dashboard - should reset to V2 based on shouldForceV2API() + await manager.loadDashboard({ uid: '', route: DashboardRoutes.New }); + // Should be back to V2 manager + expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManagerV2); + }); + }); }); const customHomeDashboardV1Spec = { diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 3a1841e6b32..3cc0df33e8a 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -1082,6 +1082,12 @@ export class UnifiedDashboardScenePageStateManager extends DashboardScenePageSta const newDashboardVersion = shouldForceV2API() ? 'v2' : 'v1'; this.setActiveManager(newDashboardVersion); } + + // Template dashboards are currently in v1 schema format. + if (options.route === DashboardRoutes.Template) { + this.setActiveManager('v1'); + } + return this.withVersionHandling((manager) => manager.loadDashboard.call(this, options)); } From d0792ebe9715ed91851c6eaf5a6494485cfe37ef Mon Sep 17 00:00:00 2001 From: Charandas <542168+charandas@users.noreply.github.com> Date: Thu, 18 Dec 2025 07:44:33 -0800 Subject: [PATCH 026/229] Secrets: Add gRPC client retry with exp. backoff" (#115526) Provisioning: secrets decrypt client should retry with exponential backoff --- pkg/registry/apis/secret/decrypt/grpc_client.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pkg/registry/apis/secret/decrypt/grpc_client.go b/pkg/registry/apis/secret/decrypt/grpc_client.go index f892de5ebe5..0612455c3d8 100644 --- a/pkg/registry/apis/secret/decrypt/grpc_client.go +++ b/pkg/registry/apis/secret/decrypt/grpc_client.go @@ -9,10 +9,13 @@ import ( "maps" "os" "slices" + "time" "github.com/fullstorydev/grpchan" + grpc_retry "github.com/grpc-ecosystem/go-grpc-middleware/retry" "go.opentelemetry.io/otel/trace" "google.golang.org/grpc" + "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/metadata" @@ -72,6 +75,15 @@ func NewGRPCDecryptClientWithTLS( opts = append(opts, grpc.WithDisableServiceConfig()) } + // Add retry interceptor to retry on transient connection issues. + // Retries on ResourceExhausted (per-RPC limits reached) and Unavailable (system unavailable). + retryInterceptor := grpc_retry.UnaryClientInterceptor( + grpc_retry.WithMax(3), + grpc_retry.WithBackoff(grpc_retry.BackoffExponentialWithJitter(time.Second, 0.5)), + grpc_retry.WithCodes(codes.ResourceExhausted, codes.Unavailable), + ) + opts = append(opts, grpc.WithUnaryInterceptor(retryInterceptor)) + conn, err := grpc.NewClient(address, opts...) if err != nil { return nil, fmt.Errorf("failed to connect to grpc decrypt server at %s: %w", address, err) From facb25a09c260bc52c301849cce82999aa8edbf3 Mon Sep 17 00:00:00 2001 From: Igor Suleymanov Date: Thu, 18 Dec 2025 18:07:48 +0200 Subject: [PATCH 027/229] Fix Grafana App SDK logger log level (#115551) * Fix Grafana App SDK logger log level What This commit fixes the hardcoded value of the app SDK logger log level by properly setting it during the log manager initialization. Why To prevent app SDK logging from always logging at DEBUG. Signed-off-by: Igor Suleymanov * Add missing argument to the logging test Signed-off-by: Igor Suleymanov --------- Signed-off-by: Igor Suleymanov --- pkg/infra/log/log.go | 31 +++++++++++++++++++++++-------- pkg/infra/log/log_test.go | 2 +- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/pkg/infra/log/log.go b/pkg/infra/log/log.go index 9e7a51a8dc7..abae387729e 100644 --- a/pkg/infra/log/log.go +++ b/pkg/infra/log/log.go @@ -54,7 +54,8 @@ func init() { } logger := level.NewFilter(format(os.Stderr), level.AllowInfo()) root = newManager(logger) - initAppSDKLogger(logger) + // Use default Info level during package initialization before config is loaded + initAppSDKLogger(logger, slog.LevelInfo) RegisterContextualLogProvider(func(ctx context.Context) ([]any, bool) { pFromCtx := ctx.Value(logParamsContextKey{}) @@ -80,7 +81,7 @@ func newManager(logger gokitlog.Logger) *logManager { } } -func (lm *logManager) initialize(loggers []logWithFilters) { +func (lm *logManager) initialize(loggers []logWithFilters, levelStr string) { lm.mutex.Lock() defer lm.mutex.Unlock() @@ -113,7 +114,7 @@ func (lm *logManager) initialize(loggers []logWithFilters) { lm.loggersByName[name].Swap(&compositeLogger{loggers: ctxLoggers}) } - initAppSDKLogger(lm.ConcreteLogger) + initAppSDKLogger(lm.ConcreteLogger, stringToSlogLevel(levelStr)) } func (lm *logManager) New(ctx ...any) *ConcreteLogger { @@ -514,7 +515,7 @@ func ReadLoggingConfig(modes []string, logsPath string, cfg *ini.File) error { configLoggers = append(configLoggers, handler) } if len(configLoggers) > 0 { - root.initialize(configLoggers) + root.initialize(configLoggers, defaultLevelName) } return nil @@ -551,8 +552,22 @@ func SetupConsoleLogger(level string) error { return nil } -func initAppSDKLogger(gkl gokitlog.Logger) { - // We need to allow Debug logs here. go-kit/log does not support sharing the level we're using. - // TODO: Refactor such that we can pass in a level in a more appropriate manner. - logging.DefaultLogger = logging.NewSLogLogger(sloggokit.NewGoKitHandler(gkl, slog.LevelDebug)) +// stringToSlogLevel converts a log level string to slog.Level +func stringToSlogLevel(levelStr string) slog.Level { + switch strings.ToLower(levelStr) { + case "trace", "debug": + return slog.LevelDebug + case "info": + return slog.LevelInfo + case "warn", "warning": + return slog.LevelWarn + case "error", "critical": + return slog.LevelError + default: + return slog.LevelInfo + } +} + +func initAppSDKLogger(gkl gokitlog.Logger, level slog.Level) { + logging.DefaultLogger = logging.NewSLogLogger(sloggokit.NewGoKitHandler(gkl, level)) } diff --git a/pkg/infra/log/log_test.go b/pkg/infra/log/log_test.go index 98fee4be8c0..5302e565964 100644 --- a/pkg/infra/log/log_test.go +++ b/pkg/infra/log/log_test.go @@ -88,7 +88,7 @@ func TestNew(t *testing.T) { val: swapLogger, maxLevel: level.AllowAll(), }, - }) + }, "info") err := log1.Log("msg", "hello 1") require.NoError(t, err) From 19f6dbe1bb8f51e6cbfdedf74d905bb78900a308 Mon Sep 17 00:00:00 2001 From: Renato Costa <103441181+renatolabs@users.noreply.github.com> Date: Thu, 18 Dec 2025 11:21:36 -0500 Subject: [PATCH 028/229] unified-storage: add `BatchGet` support to the sqlkv implementation (#115517) * unified-storage: add `BatchGet` support to the sqlkv implementation * address comments * fix linting --- .../unified/resource/data/sqlkv_batch_get.sql | 12 +++ pkg/storage/unified/resource/sqlkv.go | 78 +++++++++++++++++-- pkg/storage/unified/testing/kv.go | 75 +++++++++++------- pkg/storage/unified/testing/kv_test.go | 1 - 4 files changed, 131 insertions(+), 35 deletions(-) create mode 100644 pkg/storage/unified/resource/data/sqlkv_batch_get.sql diff --git a/pkg/storage/unified/resource/data/sqlkv_batch_get.sql b/pkg/storage/unified/resource/data/sqlkv_batch_get.sql new file mode 100644 index 00000000000..0babcb36970 --- /dev/null +++ b/pkg/storage/unified/resource/data/sqlkv_batch_get.sql @@ -0,0 +1,12 @@ +SELECT r.{{ .Ident "key_path" }}, r.{{ .Ident "value" }} +FROM ( +{{ range $id, $key_path := .KeyPaths }} + {{ if eq $id 0 }} + SELECT {{ $.Arg $id }} AS idx, {{ $.Arg $key_path }} AS key_path + {{ else }} + UNION ALL SELECT {{ $.Arg $id }}, {{ $.Arg $key_path }} + {{ end }} +{{ end }} +) AS requested_keys +INNER JOIN {{ .TableName }} r ON r.{{ .Ident "key_path" }} = requested_keys.{{ .Ident "key_path" }} +ORDER BY requested_keys.{{ .Ident "idx" }}; diff --git a/pkg/storage/unified/resource/sqlkv.go b/pkg/storage/unified/resource/sqlkv.go index a56d00a3e12..22cd3085122 100644 --- a/pkg/storage/unified/resource/sqlkv.go +++ b/pkg/storage/unified/resource/sqlkv.go @@ -34,9 +34,10 @@ func mustTemplate(filename string) *template.Template { // Templates. var ( - sqlKVGet = mustTemplate("sqlkv_get.sql") - sqlKVDelete = mustTemplate("sqlkv_delete.sql") - sqlKVKeys = mustTemplate("sqlkv_keys.sql") + sqlKVKeys = mustTemplate("sqlkv_keys.sql") + sqlKVGet = mustTemplate("sqlkv_get.sql") + sqlKVBatchGet = mustTemplate("sqlkv_batch_get.sql") + sqlKVDelete = mustTemplate("sqlkv_delete.sql") ) // sqlKVSection can be embedded in structs used when rendering query templates @@ -107,13 +108,23 @@ func (req sqlKVGetRequest) Results() ([]byte, error) { return req.Value, nil } -type sqlKVDeleteRequest struct { +type sqlKVBatchGetRequest struct { sqltemplate.SQLTemplate - sqlKVSectionKey + sqlKVSection + Keys []string } -func (req sqlKVDeleteRequest) Validate() error { - return req.sqlKVSectionKey.Validate() +func (req sqlKVBatchGetRequest) Validate() error { + return req.sqlKVSection.Validate() +} + +func (req sqlKVBatchGetRequest) KeyPaths() []string { + result := make([]string, 0, len(req.Keys)) + for _, key := range req.Keys { + result = append(result, req.Section+"/"+key) + } + + return result } type sqlKVKeysRequest struct { @@ -142,6 +153,15 @@ func (req sqlKVKeysRequest) SortAscending() bool { return req.Options.Sort != SortOrderDesc } +type sqlKVDeleteRequest struct { + sqltemplate.SQLTemplate + sqlKVSectionKey +} + +func (req sqlKVDeleteRequest) Validate() error { + return req.sqlKVSectionKey.Validate() +} + var _ KV = &sqlKV{} type sqlKV struct { @@ -188,6 +208,7 @@ func (k *sqlKV) Keys(ctx context.Context, section string, opt ListOptions) iter. yield("", err) return } + defer closeRows(rows, yield) for rows.Next() { var key string @@ -225,7 +246,41 @@ func (k *sqlKV) Get(ctx context.Context, section string, key string) (io.ReadClo func (k *sqlKV) BatchGet(ctx context.Context, section string, keys []string) iter.Seq2[KeyValue, error] { return func(yield func(KeyValue, error) bool) { - panic("not implemented!") + if len(keys) == 0 { + return + } + + rows, err := dbutil.QueryRows(ctx, k.db, sqlKVBatchGet, sqlKVBatchGetRequest{ + SQLTemplate: sqltemplate.New(k.dialect), + sqlKVSection: sqlKVSection{section}, + Keys: keys, + }) + if err != nil { + yield(KeyValue{}, err) + return + } + defer closeRows(rows, yield) + + for rows.Next() { + var key string + var value []byte + if err := rows.Scan(&key, &value); err != nil { + yield(KeyValue{}, fmt.Errorf("error reading row: %w", err)) + return + } + + kv := KeyValue{ + Key: strings.TrimPrefix(key, section+"/"), + Value: io.NopCloser(bytes.NewReader(value)), + } + if !yield(kv, nil) { + return + } + } + + if err := rows.Err(); err != nil { + yield(KeyValue{}, fmt.Errorf("failed to read rows: %w", err)) + } } } @@ -273,3 +328,10 @@ func (k *sqlKV) BatchDelete(ctx context.Context, section string, keys []string) func (k *sqlKV) UnixTimestamp(ctx context.Context) (int64, error) { panic("not implemented!") } + +func closeRows[T any](rows db.Rows, yield func(T, error) bool) { + if err := rows.Close(); err != nil { + var zero T + yield(zero, fmt.Errorf("error closing rows: %w", err)) + } +} diff --git a/pkg/storage/unified/testing/kv.go b/pkg/storage/unified/testing/kv.go index b4c61f84c13..fbd831f6281 100644 --- a/pkg/storage/unified/testing/kv.go +++ b/pkg/storage/unified/testing/kv.go @@ -103,6 +103,7 @@ func namespacedKey(nsPrefix, key string) string { func runTestKVGet(t *testing.T, kv resource.KV, nsPrefix string) { ctx := testutil.NewTestContext(t, time.Now().Add(30*time.Second)) + nsPrefix += "-get" t.Run("get existing key", func(t *testing.T) { // First save a key @@ -221,6 +222,7 @@ func runTestKVSave(t *testing.T, kv resource.KV, nsPrefix string) { func runTestKVDelete(t *testing.T, kv resource.KV, nsPrefix string) { ctx := testutil.NewTestContext(t, time.Now().Add(30*time.Second)) + nsPrefix += "-delete" t.Run("delete existing key", func(t *testing.T) { // First create a key @@ -262,6 +264,7 @@ func runTestKVDelete(t *testing.T, kv resource.KV, nsPrefix string) { func runTestKVKeys(t *testing.T, kv resource.KV, nsPrefix string) { ctx := testutil.NewTestContext(t, time.Now().Add(30*time.Second)) + nsPrefix += "-keys" // Setup test data testKeys := namespacedKeys(nsPrefix, []string{"a1", "a2", "b1", "b2", "c1"}) @@ -360,6 +363,7 @@ func runTestKVKeys(t *testing.T, kv resource.KV, nsPrefix string) { func runTestKVKeysWithLimits(t *testing.T, kv resource.KV, nsPrefix string) { ctx := testutil.NewTestContext(t, time.Now().Add(30*time.Second)) + nsPrefix += "-keys-with-limits" // Setup test data testKeys := namespacedKeys(nsPrefix, []string{"a1", "a2", "b1", "b2", "c1", "c2", "d1", "d2"}) @@ -416,6 +420,7 @@ func runTestKVKeysWithLimits(t *testing.T, kv resource.KV, nsPrefix string) { func runTestKVKeysWithSort(t *testing.T, kv resource.KV, nsPrefix string) { ctx := testutil.NewTestContext(t, time.Now().Add(30*time.Second)) + nsPrefix += "-keys-with-sort" // Setup test data testKeys := namespacedKeys(nsPrefix, []string{"a1", "a2", "b1", "b2", "c1"}) @@ -619,29 +624,29 @@ func runTestKVUnixTimestamp(t *testing.T, kv resource.KV, nsPrefix string) { func runTestKVBatchGet(t *testing.T, kv resource.KV, nsPrefix string) { ctx := testutil.NewTestContext(t, time.Now().Add(30*time.Second)) - section := nsPrefix + "-batchget" + nsPrefix += "-batchget" t.Run("batch get existing keys", func(t *testing.T) { // Setup test data testData := map[string]string{ - "key1": "value1", - "key2": "value2", - "key3": "value3", + namespacedKey(nsPrefix, "key1"): "value1", + namespacedKey(nsPrefix, "key2"): "value2", + namespacedKey(nsPrefix, "key3"): "value3", } // Save test data for key, value := range testData { - saveKVHelper(t, kv, ctx, section, key, strings.NewReader(value)) + saveKVHelper(t, kv, ctx, testSection, key, strings.NewReader(value)) } // Batch get all keys - keys := []string{"key1", "key2", "key3"} + keys := namespacedKeys(nsPrefix, []string{"key1", "key2", "key3"}) type result struct { key string value string } var results []result - for kv, err := range kv.BatchGet(ctx, section, keys) { + for kv, err := range kv.BatchGet(ctx, testSection, keys) { require.NoError(t, err) value, err := io.ReadAll(kv.Value) require.NoError(t, err) @@ -651,10 +656,10 @@ func runTestKVBatchGet(t *testing.T, kv resource.KV, nsPrefix string) { } // Verify results - assert.Len(t, results, 3) + require.Len(t, results, 3) // Check that all keys are present and in order - expectedKeys := []string{"key1", "key2", "key3"} + expectedKeys := namespacedKeys(nsPrefix, []string{"key1", "key2", "key3"}) actualKeys := make([]string, len(results)) for i, r := range results { actualKeys[i] = r.key @@ -663,22 +668,40 @@ func runTestKVBatchGet(t *testing.T, kv resource.KV, nsPrefix string) { // Verify values for _, r := range results { - assert.Equal(t, testData[r.key], r.value) + assert.Equal(t, testData[r.key], r.value, "key = %s", r.key) } }) + t.Run("batch get with empty section", func(t *testing.T) { + var kvs []resource.KeyValue + var errs []error + keys := namespacedKeys(nsPrefix, []string{"key1", "key2", "key3"}) + for kv, err := range kv.BatchGet(ctx, "", keys) { + if err != nil { + errs = append(errs, err) + continue + } + + kvs = append(kvs, kv) + } + + require.Len(t, errs, 1) + assert.Contains(t, errs[0].Error(), "section is required") + assert.Empty(t, kvs) + }) + t.Run("batch get with non-existent keys", func(t *testing.T) { // Setup some test data - saveKVHelper(t, kv, ctx, section, "existing-key", strings.NewReader("existing-value")) + saveKVHelper(t, kv, ctx, testSection, namespacedKey(nsPrefix, "existing-key"), strings.NewReader("existing-value")) // Batch get with mix of existing and non-existent keys - keys := []string{"existing-key", "non-existent-1", "non-existent-2"} + keys := namespacedKeys(nsPrefix, []string{"existing-key", "non-existent-1", "non-existent-2"}) type result struct { key string value string } var results []result - for kv, err := range kv.BatchGet(ctx, section, keys) { + for kv, err := range kv.BatchGet(ctx, testSection, keys) { require.NoError(t, err) value, err := io.ReadAll(kv.Value) require.NoError(t, err) @@ -688,15 +711,15 @@ func runTestKVBatchGet(t *testing.T, kv resource.KV, nsPrefix string) { } // Should only return the existing key - assert.Len(t, results, 1) - assert.Equal(t, "existing-key", results[0].key) + require.Len(t, results, 1) + assert.Equal(t, namespacedKey(nsPrefix, "existing-key"), results[0].key) assert.Equal(t, "existing-value", results[0].value) }) t.Run("batch get with all non-existent keys", func(t *testing.T) { - keys := []string{"non-existent-1", "non-existent-2", "non-existent-3"} + keys := namespacedKeys(nsPrefix, []string{"non-existent-1", "non-existent-2", "non-existent-3"}) var results []resource.KeyValue - for kv, err := range kv.BatchGet(ctx, section, keys) { + for kv, err := range kv.BatchGet(ctx, testSection, keys) { require.NoError(t, err) results = append(results, kv) } @@ -708,7 +731,7 @@ func runTestKVBatchGet(t *testing.T, kv resource.KV, nsPrefix string) { t.Run("batch get with empty keys list", func(t *testing.T) { keys := []string{} var results []resource.KeyValue - for kv, err := range kv.BatchGet(ctx, section, keys) { + for kv, err := range kv.BatchGet(ctx, testSection, keys) { require.NoError(t, err) results = append(results, kv) } @@ -718,16 +741,16 @@ func runTestKVBatchGet(t *testing.T, kv resource.KV, nsPrefix string) { }) t.Run("batch get with empty section", func(t *testing.T) { - keys := []string{"some-key"} + keys := namespacedKeys(nsPrefix, []string{"some-key"}) var errors []error for kv, err := range kv.BatchGet(ctx, "", keys) { if err != nil { errors = append(errors, err) - break + continue } _ = kv // unused } - assert.Len(t, errors, 1) + require.Len(t, errors, 1) assert.Contains(t, errors[0].Error(), "section is required") }) @@ -741,13 +764,13 @@ func runTestKVBatchGet(t *testing.T, kv resource.KV, nsPrefix string) { // Save test data for key, value := range testData { - saveKVHelper(t, kv, ctx, section, key, strings.NewReader(value)) + saveKVHelper(t, kv, ctx, testSection, namespacedKey(nsPrefix, key), strings.NewReader(value)) } // Batch get in specific order - keys := []string{"z-key", "a-key", "m-key"} + keys := namespacedKeys(nsPrefix, []string{"z-key", "invalid-key1", "a-key", "invalid-key2", "m-key", "invalid-key3"}) var results []string - for kv, err := range kv.BatchGet(ctx, section, keys) { + for kv, err := range kv.BatchGet(ctx, testSection, keys) { require.NoError(t, err) err = kv.Value.Close() require.NoError(t, err) @@ -755,8 +778,8 @@ func runTestKVBatchGet(t *testing.T, kv resource.KV, nsPrefix string) { } // Verify order is preserved - assert.Len(t, results, 3) - expectedOrder := []string{"z-key", "a-key", "m-key"} + require.Len(t, results, 3) + expectedOrder := namespacedKeys(nsPrefix, []string{"z-key", "a-key", "m-key"}) assert.Equal(t, expectedOrder, results) }) } diff --git a/pkg/storage/unified/testing/kv_test.go b/pkg/storage/unified/testing/kv_test.go index 780f36fcc66..dafefc15ed2 100644 --- a/pkg/storage/unified/testing/kv_test.go +++ b/pkg/storage/unified/testing/kv_test.go @@ -50,7 +50,6 @@ func TestSQLKV(t *testing.T) { TestKVSave: true, TestKVConcurrent: true, TestKVUnixTimestamp: true, - TestKVBatchGet: true, TestKVBatchDelete: true, }, }) From 1862e5dac56c018afed1e55631cd48885214eafc Mon Sep 17 00:00:00 2001 From: Mihai Doarna Date: Thu, 18 Dec 2025 18:54:55 +0200 Subject: [PATCH 029/229] IAM: Fix team search for unistore (#115250) * fix team search for unistore * fix search in unistore * remove field prefix when generating the response * fix unit test * address feedback --- pkg/registry/apis/iam/team/legacy_search.go | 7 ++++-- pkg/registry/apis/iam/team_search.go | 23 +++++++++++++++---- pkg/registry/apis/iam/team_search_test.go | 2 +- .../unified/search/builders/team_search.go | 4 ++-- .../apis/iam/team_search_integration_test.go | 5 ++-- 5 files changed, 29 insertions(+), 12 deletions(-) diff --git a/pkg/registry/apis/iam/team/legacy_search.go b/pkg/registry/apis/iam/team/legacy_search.go index 938062bd9e6..8f9594b96ac 100644 --- a/pkg/registry/apis/iam/team/legacy_search.go +++ b/pkg/registry/apis/iam/team/legacy_search.go @@ -6,6 +6,7 @@ import ( "log/slog" "math" "strconv" + "strings" "google.golang.org/grpc" @@ -102,7 +103,8 @@ func getColumns(fields []string) []*resourcepb.ResourceTableColumnDefinition { columns := getDefaultColumns() for _, field := range fields { - if col, ok := builders.TeamSearchTableColumnDefinitions[field]; ok { + fieldName := strings.TrimPrefix(field, res.SEARCH_FIELD_PREFIX) + if col, ok := builders.TeamSearchTableColumnDefinitions[fieldName]; ok { columns = append(columns, col) } } @@ -121,7 +123,8 @@ func getDefaultColumns() []*resourcepb.ResourceTableColumnDefinition { func createCells(t *team.TeamDTO, fields []string) [][]byte { cells := createDefaultCells(t) for _, field := range fields { - switch field { + fieldName := strings.TrimPrefix(field, res.SEARCH_FIELD_PREFIX) + switch fieldName { case builders.TEAM_SEARCH_EMAIL: cells = append(cells, []byte(t.Email)) case builders.TEAM_SEARCH_PROVISIONED: diff --git a/pkg/registry/apis/iam/team_search.go b/pkg/registry/apis/iam/team_search.go index 786bf9be834..2252263a708 100644 --- a/pkg/registry/apis/iam/team_search.go +++ b/pkg/registry/apis/iam/team_search.go @@ -2,6 +2,7 @@ package iam import ( "encoding/json" + "fmt" "net/http" "net/url" "strconv" @@ -12,6 +13,7 @@ import ( "k8s.io/kube-openapi/pkg/validation/spec" iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/apiserver/builder" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -137,6 +139,12 @@ func (s *TeamSearchHandler) DoTeamSearch(w http.ResponseWriter, r *http.Request) return } + requester, err := identity.GetRequester(ctx) + if err != nil { + errhttp.Write(ctx, fmt.Errorf("no identity found for request: %w", err), w) + return + } + limit := 50 offset := 0 page := 1 @@ -154,16 +162,23 @@ func (s *TeamSearchHandler) DoTeamSearch(w http.ResponseWriter, r *http.Request) } searchRequest := &resourcepb.ResourceSearchRequest{ - Options: &resourcepb.ListOptions{}, + Options: &resourcepb.ListOptions{ + Key: &resourcepb.ResourceKey{ + Group: iamv0alpha1.TeamResourceInfo.GroupResource().Group, + Resource: iamv0alpha1.TeamResourceInfo.GroupResource().Resource, + Namespace: requester.GetNamespace(), + }, + }, Query: queryParams.Get("query"), Limit: int64(limit), Offset: int64(offset), Page: int64(page), Explain: queryParams.Has("explain") && queryParams.Get("explain") != "false", Fields: []string{ - builders.TEAM_SEARCH_EMAIL, - builders.TEAM_SEARCH_PROVISIONED, - builders.TEAM_SEARCH_EXTERNAL_UID, + resource.SEARCH_FIELD_TITLE, + resource.SEARCH_FIELD_PREFIX + builders.TEAM_SEARCH_EMAIL, + resource.SEARCH_FIELD_PREFIX + builders.TEAM_SEARCH_PROVISIONED, + resource.SEARCH_FIELD_PREFIX + builders.TEAM_SEARCH_EXTERNAL_UID, }, } diff --git a/pkg/registry/apis/iam/team_search_test.go b/pkg/registry/apis/iam/team_search_test.go index 76efed1c067..2142ba4e505 100644 --- a/pkg/registry/apis/iam/team_search_test.go +++ b/pkg/registry/apis/iam/team_search_test.go @@ -89,7 +89,7 @@ func TestTeamSearchHandler(t *testing.T) { if mockClient.LastSearchRequest == nil { t.Fatalf("expected Search to be called, but it was not") } - expectedFields := []string{"email", "provisioned", "externalUID"} + expectedFields := []string{"title", "fields.email", "fields.provisioned", "fields.externalUID"} if fmt.Sprintf("%v", mockClient.LastSearchRequest.Fields) != fmt.Sprintf("%v", expectedFields) { t.Errorf("expected fields %v, got %v", expectedFields, mockClient.LastSearchRequest.Fields) } diff --git a/pkg/storage/unified/search/builders/team_search.go b/pkg/storage/unified/search/builders/team_search.go index 4b09074b3dc..3dcf3cfdc95 100644 --- a/pkg/storage/unified/search/builders/team_search.go +++ b/pkg/storage/unified/search/builders/team_search.go @@ -46,8 +46,8 @@ func GetTeamSearchBuilder() (resource.DocumentBuilderInfo, error) { return resource.DocumentBuilderInfo{ GroupResource: schema.GroupResource{ - Group: "iam.grafana.app", - Resource: "searchTeams", + Group: v0alpha1.TeamResourceInfo.GroupResource().Group, + Resource: v0alpha1.TeamResourceInfo.GroupResource().Resource, }, Fields: fields, Builder: new(teamSearchBuilder), diff --git a/pkg/tests/apis/iam/team_search_integration_test.go b/pkg/tests/apis/iam/team_search_integration_test.go index c01ca9a641a..bf02f5718c9 100644 --- a/pkg/tests/apis/iam/team_search_integration_test.go +++ b/pkg/tests/apis/iam/team_search_integration_test.go @@ -21,8 +21,7 @@ import ( func TestIntegrationTeamSearch(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) - // TODO: Add rest.Mode3 and rest.Mode4 when they're supported - modes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2} + modes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3, rest.Mode4, rest.Mode5} for _, mode := range modes { t.Run(fmt.Sprintf("Team search with dual writer mode %d", mode), func(t *testing.T) { helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ @@ -38,6 +37,7 @@ func TestIntegrationTeamSearch(t *testing.T) { featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, featuremgmt.FlagKubernetesAuthnMutation, }, + UnifiedStorageEnableSearch: true, }) doTeamSearchTests(t, helper) }) @@ -59,7 +59,6 @@ func doTeamSearchTests(t *testing.T, helper *apis.K8sTestHelper) { require.NoError(t, err) require.NotNil(t, team1) - // Create a second team with a different name team2YAML := helper.LoadYAMLOrJSONFile("testdata/team-test-create-v0.yaml") team2YAML.Object["metadata"].(map[string]interface{})["name"] = "testteam2" team2YAML.Object["spec"].(map[string]interface{})["title"] = "Another Team" From 051cdaad0d3d5c2f1e9f976fb2f87d6d5c6baf7e Mon Sep 17 00:00:00 2001 From: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Thu, 18 Dec 2025 11:11:33 -0600 Subject: [PATCH 030/229] Revert "Plugins: Add PluginInsights UI (#111603)" (#115574) This reverts commit 1f4f2b4d7c6af8fb32fbba448a95d85e8886d632. --- eslint-suppressions.json | 5 + .../src/types/featureToggles.gen.ts | 5 - pkg/services/featuremgmt/registry.go | 8 - pkg/services/featuremgmt/toggles_gen.csv | 1 - pkg/services/featuremgmt/toggles_gen.json | 14 -- public/app/features/plugins/admin/api.ts | 16 -- .../components/PluginDetailsPage.test.tsx | 2 - .../admin/components/PluginDetailsPage.tsx | 21 +-- .../components/PluginDetailsPanel.test.tsx | 71 +------- .../admin/components/PluginDetailsPanel.tsx | 9 +- .../admin/components/PluginInsights.test.tsx | 171 ------------------ .../admin/components/PluginInsights.tsx | 140 -------------- .../plugins/admin/mocks/catalogPlugin.mock.ts | 2 - .../plugins/admin/mocks/mockHelpers.ts | 8 - .../features/plugins/admin/state/actions.ts | 20 +- .../app/features/plugins/admin/state/hooks.ts | 29 +-- .../features/plugins/admin/state/reducer.ts | 5 - public/app/features/plugins/admin/types.ts | 49 ----- public/locales/en-US/grafana.json | 6 - 19 files changed, 16 insertions(+), 566 deletions(-) delete mode 100644 public/app/features/plugins/admin/components/PluginInsights.test.tsx delete mode 100644 public/app/features/plugins/admin/components/PluginInsights.tsx diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 80d18ce0cae..1cdf9de4c4a 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2868,6 +2868,11 @@ "count": 1 } }, + "public/app/features/plugins/admin/components/PluginDetailsPage.tsx": { + "@typescript-eslint/consistent-type-assertions": { + "count": 1 + } + }, "public/app/features/plugins/admin/helpers.ts": { "no-restricted-syntax": { "count": 2 diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 6ce08b92452..b91161d966c 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1189,11 +1189,6 @@ export interface FeatureToggles { */ onlyStoreActionSets?: boolean; /** - * Show insights for plugins in the plugin details page - * @default false - */ - pluginInsights?: boolean; - /** * Enables a new panel time settings drawer */ panelTimeSettings?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index af82c05e61d..d8a46601ad6 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1960,14 +1960,6 @@ var ( Owner: identityAccessTeam, Expression: "true", }, - { - Name: "pluginInsights", - Description: "Show insights for plugins in the plugin details page", - Stage: FeatureStageExperimental, - FrontendOnly: true, - Owner: grafanaPluginsPlatformSquad, - Expression: "false", - }, { Name: "panelTimeSettings", Description: "Enables a new panel time settings drawer", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index fedcd197a1f..3fbc019bff1 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -266,7 +266,6 @@ jaegerEnableGrpcEndpoint,experimental,@grafana/oss-big-tent,false,false,false pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,false,false newPanelPadding,preview,@grafana/dashboards-squad,false,false,true onlyStoreActionSets,GA,@grafana/identity-access-team,false,false,false -pluginInsights,experimental,@grafana/plugins-platform-backend,false,false,true panelTimeSettings,experimental,@grafana/dashboards-squad,false,false,false elasticsearchRawDSLQuery,experimental,@grafana/partner-datasources,false,false,false kubernetesAnnotations,experimental,@grafana/grafana-backend-services-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 3c33f362b29..866f8a78881 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2693,20 +2693,6 @@ "expression": "false" } }, - { - "metadata": { - "name": "pluginInsights", - "resourceVersion": "1761300628147", - "creationTimestamp": "2025-10-24T10:10:28Z" - }, - "spec": { - "description": "Show insights for plugins in the plugin details page", - "stage": "experimental", - "codeowner": "@grafana/plugins-platform-backend", - "frontend": true, - "expression": "false" - } - }, { "metadata": { "name": "pluginInstallAPISync", diff --git a/public/app/features/plugins/admin/api.ts b/public/app/features/plugins/admin/api.ts index aa5bc32f183..74a072ba054 100644 --- a/public/app/features/plugins/admin/api.ts +++ b/public/app/features/plugins/admin/api.ts @@ -8,7 +8,6 @@ import { LocalPlugin, RemotePlugin, CatalogPluginDetails, - CatalogPluginInsights, Version, PluginVersion, InstancePlugin, @@ -48,21 +47,6 @@ export async function getPluginDetails(id: string): Promise { - if (!version) { - throw new Error('Version is required'); - } - try { - const insights = await getBackendSrv().get(`${GCOM_API_ROOT}/plugins/${id}/versions/${version}/insights`); - return insights; - } catch (error) { - if (isFetchError(error)) { - error.isHandled = true; - } - throw error; - } -} - export async function getRemotePlugins(): Promise { try { const { items: remotePlugins }: { items: RemotePlugin[] } = await getBackendSrv().get(`${GCOM_API_ROOT}/plugins`, { diff --git a/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx b/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx index 0ffc93f8f77..da4eef2f0d4 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx @@ -62,12 +62,10 @@ const plugin: CatalogPlugin = { angularDetected: false, isFullyInstalled: true, accessControl: {}, - insights: { id: 1, name: 'test-plugin', version: '1.0.0', insights: [] }, }; jest.mock('../state/hooks', () => ({ useGetSingle: jest.fn(), - useGetPluginInsights: jest.fn(), useFetchStatus: jest.fn().mockReturnValue({ isLoading: false }), useFetchDetailsStatus: () => ({ isLoading: false }), useIsRemotePluginsAvailable: () => false, diff --git a/public/app/features/plugins/admin/components/PluginDetailsPage.tsx b/public/app/features/plugins/admin/components/PluginDetailsPage.tsx index b135321e558..0e651a8e4bf 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsPage.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsPage.tsx @@ -16,19 +16,11 @@ import { PluginDetailsPanel } from '../components/PluginDetailsPanel'; import { PluginDetailsSignature } from '../components/PluginDetailsSignature'; import { usePluginDetailsTabs } from '../hooks/usePluginDetailsTabs'; import { usePluginPageExtensions } from '../hooks/usePluginPageExtensions'; -import { useGetSingle, useFetchStatus, useFetchDetailsStatus, useGetPluginInsights } from '../state/hooks'; +import { useGetSingle, useFetchStatus, useFetchDetailsStatus } from '../state/hooks'; import { PluginTabIds } from '../types'; import { PluginDetailsDeprecatedWarning } from './PluginDetailsDeprecatedWarning'; -function isPluginTabId(value: string | null): value is PluginTabIds { - if (!value) { - return false; - } - const validIds: string[] = Object.values(PluginTabIds); - return validIds.includes(value); -} - export type Props = { // The ID of the plugin pluginId: string; @@ -57,13 +49,12 @@ export function PluginDetailsPage({ }; const queryParams = new URLSearchParams(location.search); const plugin = useGetSingle(pluginId); // fetches the plugin settings for this Grafana instance - useGetPluginInsights(pluginId, plugin?.isInstalled ? plugin?.installedVersion : plugin?.latestVersion); - const isNarrowScreen = useMedia('(max-width: 600px)'); - const pageParam = queryParams.get('page'); - const pageId = pageParam && isPluginTabId(pageParam) ? pageParam : undefined; - const { navModel, activePageId } = usePluginDetailsTabs(plugin, pageId, isNarrowScreen); - + const { navModel, activePageId } = usePluginDetailsTabs( + plugin, + queryParams.get('page') as PluginTabIds, + isNarrowScreen + ); const { actions, info, subtitle } = usePluginPageExtensions(plugin); const { isLoading: isFetchLoading } = useFetchStatus(); const { isLoading: isFetchDetailsLoading } = useFetchDetailsStatus(); diff --git a/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx b/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx index 20787099842..eade37f559c 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx @@ -1,23 +1,11 @@ -import userEvent from '@testing-library/user-event'; import { render, screen } from 'test/test-utils'; import { PluginSignatureStatus, PluginSignatureType, PluginType } from '@grafana/data'; -import { config } from '@grafana/runtime'; -import { CatalogPlugin, SCORE_LEVELS } from '../types'; +import { CatalogPlugin } from '../types'; import { PluginDetailsPanel } from './PluginDetailsPanel'; -jest.mock('@grafana/runtime', () => ({ - ...jest.requireActual('@grafana/runtime'), - config: { - ...jest.requireActual('@grafana/runtime').config, - featureToggles: { - pluginInsights: false, - }, - }, -})); - const mockPlugin: CatalogPlugin = { description: 'Test plugin description', downloads: 1000, @@ -197,61 +185,4 @@ describe('PluginDetailsPanel', () => { expect(regularLinks).toContainElement(raiseIssueLink); expect(regularLinks).not.toContainElement(websiteLink); }); - - it('should render plugin insights when plugin has insights', async () => { - config.featureToggles.pluginInsights = true; - const pluginWithInsights = { - ...mockPlugin, - insights: { - id: 1, - name: 'test-plugin', - version: '1.0.0', - insights: [ - { - name: 'security', - scoreValue: 90, - scoreLevel: SCORE_LEVELS.EXCELLENT, - items: [ - { - id: 'signature', - name: 'Signature verified', - level: 'ok' as const, - }, - ], - }, - ], - }, - }; - render(); - expect(screen.getByTestId('plugin-insights-container')).toBeInTheDocument(); - expect(screen.getByText('Plugin insights')).toBeInTheDocument(); - expect(screen.queryByText('Security')).toBeInTheDocument(); - await userEvent.click(screen.getByText('Security')); - expect(screen.getByTestId('plugin-insight-item-signature')).toBeInTheDocument(); - }); - - it('should not render plugin insights when plugin has no insights', () => { - const pluginWithoutInsights = { - ...mockPlugin, - insights: undefined, - }; - render(); - expect(screen.queryByTestId('plugin-insights-container')).not.toBeInTheDocument(); - expect(screen.queryByText('Plugin insights')).not.toBeInTheDocument(); - }); - - it('should not render plugin insights when insights array is empty', () => { - const pluginWithEmptyInsights = { - ...mockPlugin, - insights: { - id: 1, - name: 'test-plugin', - version: '1.0.0', - insights: [], - }, - }; - render(); - expect(screen.queryByTestId('plugin-insights-container')).not.toBeInTheDocument(); - expect(screen.queryByText('Plugin insights')).not.toBeInTheDocument(); - }); }); diff --git a/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx b/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx index aa8b6c792ef..00211b61c6e 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; -import { config, reportInteraction } from '@grafana/runtime'; +import { reportInteraction } from '@grafana/runtime'; import { PageInfoItem } from '@grafana/runtime/internal'; import { Stack, @@ -22,8 +22,6 @@ import { formatDate } from 'app/core/internationalization/dates'; import { CatalogPlugin } from '../types'; -import { PluginInsights } from './PluginInsights'; - type Props = { pluginExtentionsInfo: PageInfoItem[]; plugin: CatalogPlugin; width?: string }; export function PluginDetailsPanel(props: Props): React.ReactElement | null { @@ -71,11 +69,6 @@ export function PluginDetailsPanel(props: Props): React.ReactElement | null { return ( <> - {config.featureToggles.pluginInsights && plugin.insights && plugin.insights?.insights?.length > 0 && ( - - - - )} {pluginExtentionsInfo.map((infoItem, index) => { diff --git a/public/app/features/plugins/admin/components/PluginInsights.test.tsx b/public/app/features/plugins/admin/components/PluginInsights.test.tsx deleted file mode 100644 index efd064c7172..00000000000 --- a/public/app/features/plugins/admin/components/PluginInsights.test.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import userEvent from '@testing-library/user-event'; -import { render, screen } from 'test/test-utils'; - -import { CatalogPluginInsights, InsightLevel, SCORE_LEVELS } from '../types'; - -import { PluginInsights } from './PluginInsights'; - -const mockPluginInsights: CatalogPluginInsights = { - id: 1, - name: 'test-plugin', - version: '1.0.0', - insights: [ - { - name: 'security', - scoreValue: 90, - scoreLevel: SCORE_LEVELS.EXCELLENT, - items: [ - { - id: 'signature', - name: 'Signature verified', - description: 'Plugin signature is valid', - level: 'ok' as InsightLevel, - }, - { - id: 'trackingscripts', - name: 'No unsafe JavaScript detected', - level: 'good' as InsightLevel, - }, - ], - }, - { - name: 'quality', - scoreValue: 60, - scoreLevel: SCORE_LEVELS.FAIR, - items: [ - { - id: 'metadatavalid', - name: 'Metadata is valid', - level: 'ok' as InsightLevel, - }, - { - id: 'code-rules', - name: 'Missing code rules', - description: 'Plugin lacks comprehensive code rules', - level: 'warning' as InsightLevel, - }, - ], - }, - ], -}; - -const mockPluginInsightsWithPoorLevel: CatalogPluginInsights = { - id: 3, - name: 'test-plugin-poor', - version: '0.8.0', - insights: [ - { - name: 'quality', - scoreValue: 35, - scoreLevel: SCORE_LEVELS.POOR, - items: [ - { - id: 'legacy-platform', - name: 'Quality issues detected', - level: 'warning' as InsightLevel, - }, - ], - }, - ], -}; - -describe('PluginInsights', () => { - it('should render plugin insights section', () => { - render(); - const insightsSection = screen.getByTestId('plugin-insights-container'); - expect(insightsSection).toBeInTheDocument(); - expect(screen.getByText('Plugin insights')).toBeInTheDocument(); - }); - - it('should render all insight categories with test ids', () => { - render(); - expect(screen.getByTestId('plugin-insight-security')).toBeInTheDocument(); - expect(screen.getByTestId('plugin-insight-quality')).toBeInTheDocument(); - }); - - it('should render category names with test ids', () => { - render(); - const securityCategory = screen.getByTestId('plugin-insight-security'); - const qualityCategory = screen.getByTestId('plugin-insight-quality'); - - expect(securityCategory).toBeInTheDocument(); - expect(securityCategory).toHaveTextContent('Security'); - expect(qualityCategory).toBeInTheDocument(); - expect(qualityCategory).toHaveTextContent('Quality'); - }); - - it('should render individual insight items with test ids', async () => { - render(); - await userEvent.click(screen.getByText('Security')); - expect(screen.getByTestId('plugin-insight-item-signature')).toBeInTheDocument(); - expect(screen.getByTestId('plugin-insight-item-trackingscripts')).toBeInTheDocument(); - await userEvent.click(screen.getByText('Quality')); - expect(screen.getByTestId('plugin-insight-item-metadatavalid')).toBeInTheDocument(); - expect(screen.getByTestId('plugin-insight-item-code-rules')).toBeInTheDocument(); - }); - - it('should display correct icons for Excellent score level', () => { - render(); - - const securityCategory = screen.getByTestId('plugin-insight-security'); - const securityIcon = securityCategory.querySelector('[data-testid="excellent-icon"]'); - expect(securityIcon).toBeInTheDocument(); - }); - - it('should display correct icons for Poor score levels', () => { - // Test Poor level - should show exclamation-triangle - render(); - const poorCategory = screen.getByTestId('plugin-insight-quality'); - const poorIcon = poorCategory.querySelector('[data-testid="poor-icon"]'); - expect(poorIcon).toBeInTheDocument(); - }); - - it('should handle multiple items with different insight levels', async () => { - const multiLevelInsights: CatalogPluginInsights = { - id: 5, - name: 'multi-level-plugin', - version: '2.0.0', - insights: [ - { - name: 'quality', - scoreValue: 75, - scoreLevel: SCORE_LEVELS.GOOD, - items: [ - { - id: 'code-rules', - name: 'Info level item', - level: 'info' as InsightLevel, - }, - { - id: 'sdk-usage', - name: 'OK level item', - level: 'ok' as InsightLevel, - }, - { - id: 'jsMap', - name: 'Good level item', - level: 'good' as InsightLevel, - }, - { - id: 'gosec', - name: 'Warning level item', - level: 'warning' as InsightLevel, - }, - { - id: 'legacy-builder', - name: 'Danger level item', - level: 'danger' as InsightLevel, - }, - ], - }, - ], - }; - render(); - await userEvent.click(screen.getByText('Quality')); - expect(screen.getByText('Info level item')).toBeInTheDocument(); - expect(screen.getByText('OK level item')).toBeInTheDocument(); - expect(screen.getByText('Good level item')).toBeInTheDocument(); - expect(screen.getByText('Warning level item')).toBeInTheDocument(); - expect(screen.getByText('Danger level item')).toBeInTheDocument(); - }); -}); diff --git a/public/app/features/plugins/admin/components/PluginInsights.tsx b/public/app/features/plugins/admin/components/PluginInsights.tsx deleted file mode 100644 index 805bcf926bf..00000000000 --- a/public/app/features/plugins/admin/components/PluginInsights.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { css } from '@emotion/css'; -import { capitalize } from 'lodash'; -import { useState } from 'react'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { Trans } from '@grafana/i18n'; -import { reportInteraction } from '@grafana/runtime'; -import { Stack, Text, TextLink, CollapsableSection, Tooltip, Icon, useStyles2, useTheme2 } from '@grafana/ui'; - -import { CatalogPluginInsights } from '../types'; - -type Props = { pluginInsights: CatalogPluginInsights | undefined }; - -const PLUGINS_INSIGHTS_OPENED_EVENT_NAME = 'plugins_insights_opened'; - -export function PluginInsights(props: Props): React.ReactElement | null { - const { pluginInsights } = props; - const styles = useStyles2(getStyles); - const theme = useTheme2(); - const [openInsights, setOpenInsights] = useState>({}); - - const handleInsightToggle = (insightName: string, isOpen: boolean) => { - if (isOpen) { - reportInteraction(PLUGINS_INSIGHTS_OPENED_EVENT_NAME, { insight: insightName }); - } - setOpenInsights((prev) => ({ ...prev, [insightName]: isOpen })); - }; - - const tooltipInfo = ( - - - - - - All relevant signals are present and verified - - - - - - - - One or more signals are missing or need attention - - - -
- - - Do you find Plugin Insights usefull? Please share your feedback{' '} - - here - - . - - -
- ); - - return ( - <> - - - - Plugin insights - - - - - - {pluginInsights?.insights.map((insightItem, index) => { - return ( - - handleInsightToggle(insightItem.name, isOpen)} - label={ - - {insightItem.scoreLevel === 'Excellent' ? ( - - ) : ( - - )} - - {capitalize(insightItem.name)} - - - } - contentClassName={styles.pluginInsightsItems} - > - - {insightItem.items.map((item, idx) => ( - - - {item.level === 'good' ? ( - - ) : ( - - )} - - - {item.name} - - - ))} - - - - ); - })} - - - ); -} - -export const getStyles = (theme: GrafanaTheme2) => { - return { - pluginVersionDetails: css({ wordBreak: 'break-word' }), - pluginInsightsItems: css({ marginLeft: '26px', paddingTop: '0 !important' }), - pluginInsightsTooltipSeparator: css({ - border: 'none', - borderTop: `1px solid ${theme.colors.border.medium}`, - margin: `${theme.spacing(1)} 0`, - }), - }; -}; diff --git a/public/app/features/plugins/admin/mocks/catalogPlugin.mock.ts b/public/app/features/plugins/admin/mocks/catalogPlugin.mock.ts index 3625b687f7b..9ced4f20a84 100644 --- a/public/app/features/plugins/admin/mocks/catalogPlugin.mock.ts +++ b/public/app/features/plugins/admin/mocks/catalogPlugin.mock.ts @@ -34,7 +34,6 @@ export default { updatedAt: '2021-08-25T15:03:49.000Z', version: '4.2.2', error: undefined, - insights: { id: 1, name: 'alexanderzobnin-zabbix-app', version: '4.2.2', insights: [] }, details: { grafanaDependency: '>=8.0.0', pluginDependencies: [], @@ -382,7 +381,6 @@ export const datasourcePlugin = { angularDetected: false, isFullyInstalled: true, latestVersion: '1.20.0', - insights: { id: 2, name: 'grafana-redshift-datasource', version: '1.20.0', insights: [] }, details: { grafanaDependency: '>=8.0.0', pluginDependencies: [], diff --git a/public/app/features/plugins/admin/mocks/mockHelpers.ts b/public/app/features/plugins/admin/mocks/mockHelpers.ts index d6e04186f77..6034e8860e9 100644 --- a/public/app/features/plugins/admin/mocks/mockHelpers.ts +++ b/public/app/features/plugins/admin/mocks/mockHelpers.ts @@ -31,9 +31,6 @@ export const getPluginsStateMock = (plugins: CatalogPlugin[] = []): ReducerState 'plugins/fetchDetails': { status: RequestStatus.Fulfilled, }, - 'plugins/fetchPluginInsights': { - status: RequestStatus.Fulfilled, - }, }, // Backward compatibility plugins: [], @@ -78,11 +75,6 @@ export const mockPluginApis = ({ return Promise.resolve({ items: versions }); } - // Mock plugin insights - return empty insights to avoid API call errors - if (path.includes('/insights')) { - return Promise.resolve({ id: 1, name: '', version: '', insights: [] }); - } - // Mock local plugin settings (installed) if necessary if (local && path === `${API_ROOT}/${local.id}/settings`) { return Promise.resolve(local); diff --git a/public/app/features/plugins/admin/state/actions.ts b/public/app/features/plugins/admin/state/actions.ts index e9cf2d9d40d..6be68111dd5 100644 --- a/public/app/features/plugins/admin/state/actions.ts +++ b/public/app/features/plugins/admin/state/actions.ts @@ -13,7 +13,6 @@ import { getPluginErrors, getLocalPlugins, getPluginDetails, - getPluginInsights, installPlugin, uninstallPlugin, getInstancePlugins, @@ -166,22 +165,6 @@ export const fetchDetails = createAsyncThunk, stri } ); -export const fetchPluginInsights = createAsyncThunk, { id: string; version?: string }>( - `${STATE_PREFIX}/fetchPluginInsights`, - async ({ id, version }, thunkApi) => { - try { - const insights = await getPluginInsights(id, version); - - return { - id, - changes: { insights }, - }; - } catch (e) { - return thunkApi.rejectWithValue('Unknown error.'); - } - } -); - export const addPlugins = createAction(`${STATE_PREFIX}/addPlugins`); // 1. gets remote equivalents from the store (if there are any) @@ -282,8 +265,7 @@ export const panelPluginLoaded = createAction(`${STATE_PREFIX}/pane // TODO export const loadPanelPlugin = (id: string): ThunkResult> => { return async (dispatch, getStore) => { - const state = getStore(); - let plugin = state.plugins.panels[id]; + let plugin = getStore().plugins.panels[id]; if (!plugin) { plugin = await importPanelPlugin(id); diff --git a/public/app/features/plugins/admin/state/hooks.ts b/public/app/features/plugins/admin/state/hooks.ts index 6eb47d7e1aa..2185ec99465 100644 --- a/public/app/features/plugins/admin/state/hooks.ts +++ b/public/app/features/plugins/admin/state/hooks.ts @@ -6,16 +6,7 @@ import { useDispatch, useSelector } from 'app/types/store'; import { sortPlugins, Sorters, isPluginUpdatable } from '../helpers'; import { CatalogPlugin, PluginStatus } from '../types'; -import { - fetchAll, - fetchDetails, - fetchRemotePlugins, - install, - uninstall, - fetchAllLocal, - unsetInstall, - fetchPluginInsights, -} from './actions'; +import { fetchAll, fetchDetails, fetchRemotePlugins, install, uninstall, fetchAllLocal, unsetInstall } from './actions'; import { selectPlugins, selectById, @@ -53,18 +44,13 @@ export const useGetUpdatable = () => { }; }; -export const useGetSingle = (id: string, version?: string): CatalogPlugin | undefined => { +export const useGetSingle = (id: string): CatalogPlugin | undefined => { useFetchAll(); useFetchDetails(id); return useSelector((state) => selectById(state, id)); }; -export const useGetPluginInsights = (id: string, version: string | undefined): CatalogPlugin | undefined => { - useFetchPluginInsights(id, version); - return useSelector((state) => selectById(state, id)); -}; - export const useGetSingleLocalWithoutDetails = (id: string): CatalogPlugin | undefined => { useFetchAllLocal(); return useSelector((state) => selectById(state, id)); @@ -167,17 +153,6 @@ export const useFetchDetails = (id: string) => { }, [plugin]); // eslint-disable-line }; -export const useFetchPluginInsights = (id: string, version: string | undefined) => { - const dispatch = useDispatch(); - const plugin = useSelector((state) => selectById(state, id)); - const isNotFetching = !useSelector(selectIsRequestPending(fetchPluginInsights.typePrefix)); - const shouldFetch = isNotFetching && plugin && !plugin.insights && version; - - useEffect(() => { - shouldFetch && dispatch(fetchPluginInsights({ id, version })); - }, [plugin, version]); // eslint-disable-line -}; - export const useFetchDetailsLazy = () => { const dispatch = useDispatch(); diff --git a/public/app/features/plugins/admin/state/reducer.ts b/public/app/features/plugins/admin/state/reducer.ts index e3d5bec5427..f2414a31405 100644 --- a/public/app/features/plugins/admin/state/reducer.ts +++ b/public/app/features/plugins/admin/state/reducer.ts @@ -7,7 +7,6 @@ import { CatalogPlugin, ReducerState, RequestStatus } from '../types'; import { fetchDetails, - fetchPluginInsights, install, uninstall, loadPluginDashboards, @@ -64,10 +63,6 @@ const slice = createSlice({ .addCase(fetchDetails.fulfilled, (state, action) => { pluginsAdapter.updateOne(state.items, action.payload); }) - // Fetch Plugin Insights - .addCase(fetchPluginInsights.fulfilled, (state, action) => { - pluginsAdapter.updateOne(state.items, action.payload); - }) // Install .addCase(install.fulfilled, (state, action) => { pluginsAdapter.updateOne(state.items, action.payload); diff --git a/public/app/features/plugins/admin/types.ts b/public/app/features/plugins/admin/types.ts index df4114101b4..3cc66bba0b9 100644 --- a/public/app/features/plugins/admin/types.ts +++ b/public/app/features/plugins/admin/types.ts @@ -55,7 +55,6 @@ export interface CatalogPlugin extends WithAccessControlMetadata { updatedAt: string; installedVersion?: string; details?: CatalogPluginDetails; - insights?: CatalogPluginInsights; error?: PluginErrorCode; angularDetected?: boolean; // instance plugins may not be fully installed, which means a new instance @@ -91,54 +90,6 @@ export interface CatalogPluginDetails { screenshots?: Screenshots[] | null; } -export type InsightLevel = 'ok' | 'warning' | 'danger' | 'good' | 'info'; - -export const SCORE_LEVELS = { - EXCELLENT: 'Excellent', - GOOD: 'Good', - FAIR: 'Fair', - POOR: 'Poor', - CRITICAL: 'Critical', -} as const; - -export type ScoreLevel = (typeof SCORE_LEVELS)[keyof typeof SCORE_LEVELS]; - -export const INSIGHT_CATEGORIES = { - SECURITY: 'security', - QUALITY: 'quality', - PERFORMANCE: 'performance', -} as const; - -export const INSIGHT_LEVELS = { - GOOD: 'good', - OK: 'ok', - WARNING: 'warning', - DANGER: 'danger', - INFO: 'info', -} as const; - -export interface InsightItem { - id: string; - name: string; - description?: string; - level: InsightLevel; - link?: string; -} - -export interface InsightCategory { - name: string; - items: InsightItem[]; - scoreValue: number; - scoreLevel: ScoreLevel; -} - -export interface CatalogPluginInsights { - id: number; - name: string; - version: string; - insights: InsightCategory[]; -} - export interface CatalogPluginInfo { logos: { large: string; small: string }; keywords: string[]; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 2e86a2a42d3..6949fa04bb3 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -11390,12 +11390,6 @@ "latestReleaseDate": "Latest release date:", "latestVersion": "Latest Version", "license": "License", - "moreDetails": "Do you find Plugin Insights usefull? Please share your feedback <2>here.", - "pluginInsights": { - "header": "Plugin insights" - }, - "pluginInsightsSuccessTooltip": "All relevant signals are present and verified", - "pluginInsightsWarningTooltip": "One or more signals are missing or need attention", "raiseAnIssue": "Raise an issue", "reportAbuse": "Report a concern", "reportAbuseTooltip": "Report issues related to malicious or harmful plugins directly to Grafana Labs.", From 26ce2c09d78706e4235cc378d67da9f99c6ba99d Mon Sep 17 00:00:00 2001 From: Denis Vodopianov Date: Thu, 18 Dec 2025 19:10:30 +0100 Subject: [PATCH 031/229] chore: a drop-in replacement for FeatureToggles.IsEnabledGlobally in app settings (#113449) --- pkg/registry/apps/apps.go | 2 +- pkg/setting/setting.go | 5 +++++ pkg/setting/startup_setting.go | 14 ++++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 pkg/setting/startup_setting.go diff --git a/pkg/registry/apps/apps.go b/pkg/registry/apps/apps.go index a1ec8aafd65..77c96fd437c 100644 --- a/pkg/registry/apps/apps.go +++ b/pkg/registry/apps/apps.go @@ -76,7 +76,7 @@ func ProvideAppInstallers( if features.IsEnabledGlobally(featuremgmt.FlagKubernetesLogsDrilldown) { installers = append(installers, logsdrilldownAppInstaller) } - //nolint:staticcheck + //nolint:staticcheck // not yet migrated to OpenFeature if features.IsEnabledGlobally(featuremgmt.FlagKubernetesAnnotations) { installers = append(installers, annotationAppInstaller) } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 1af02aa0d3a..3d202b20cec 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -510,6 +510,9 @@ type Cfg struct { // Query history QueryHistoryEnabled bool + // StartupSettings settings + StartupSettings StartupSettings + // Open feature settings OpenFeature OpenFeatureSettings @@ -1471,6 +1474,8 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error { // unified storage config cfg.setUnifiedStorageConfig() + // app platform config + cfg.readStartupSettingsSection() return nil } diff --git a/pkg/setting/startup_setting.go b/pkg/setting/startup_setting.go new file mode 100644 index 00000000000..8b2c2fa6362 --- /dev/null +++ b/pkg/setting/startup_setting.go @@ -0,0 +1,14 @@ +package setting + +type StartupSettings struct { + KubernetesAnnotationsAppEnabled bool +} + +func (cfg *Cfg) readStartupSettingsSection() { + settings := StartupSettings{} + + startupSettingsSection := cfg.Raw.Section("startup_settings") + settings.KubernetesAnnotationsAppEnabled = startupSettingsSection.Key("annotations_app_enabled").MustBool(false) + + cfg.StartupSettings = settings +} From 18501633467c50ec9afb4b224eb803fdf91d38b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20Fern=C3=A1ndez?= Date: Thu, 18 Dec 2025 19:47:04 +0100 Subject: [PATCH 032/229] Rudderstack: Add new config option for rudderstack v3 url (#115374) --- conf/defaults.ini | 3 +++ conf/sample.ini | 3 +++ .../setup-grafana/configure-grafana/_index.md | 6 ++++++ packages/grafana-data/src/types/config.ts | 1 + packages/grafana-runtime/src/config.ts | 1 + pkg/api/dtos/frontend_settings.go | 1 + pkg/api/frontendsettings.go | 1 + pkg/services/frontend/frontend_settings.go | 1 + pkg/services/frontend/index.go | 1 + pkg/setting/setting.go | 2 ++ public/app/core/services/echo/init.ts | 16 +++++++--------- 11 files changed, 27 insertions(+), 9 deletions(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index de83393e43d..8ee03e6a34c 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -335,6 +335,9 @@ rudderstack_data_plane_url = # Rudderstack SDK url, optional, only valid if rudderstack_write_key and rudderstack_data_plane_url is also set rudderstack_sdk_url = +# Rudderstack v3 SDK, optional, defaults to false. If set, Rudderstack v3 SDK will be used instead of v1 +rudderstack_v3_sdk_url = + # Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config rudderstack_config_url = diff --git a/conf/sample.ini b/conf/sample.ini index d1d50f0a72a..0bb5b82fdc9 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -322,6 +322,9 @@ # Rudderstack SDK url, optional, only valid if rudderstack_write_key and rudderstack_data_plane_url is also set ;rudderstack_sdk_url = +# Rudderstack v3 SDK, optional, defaults to false. If set, Rudderstack v3 SDK will be used instead of v1 +;rudderstack_v3_sdk_url = + # Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config ;rudderstack_config_url = diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index a82ca8f91dd..05d9cb66228 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -642,6 +642,12 @@ You must also provide the `rudderstack_write_key` to enable this feature. Optional. If tracking with RudderStack is enabled, you can provide a custom URL to load the RudderStack SDK. +#### `rudderstack_v3_sdk_url` + +Optional. +This is mirroring the old configuration option, which will be deprecated. +If `rudderstack_sdk_url` and `rudderstack_v3_sdk_url` are both set, the feature toggle `rudderstackUpgrade` will control which one is loaded. + #### `rudderstack_config_url` Optional. diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index f519b1d56b4..b2d3c16a3b1 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -289,6 +289,7 @@ export interface GrafanaConfig { rudderstackWriteKey: string; rudderstackDataPlaneUrl: string; rudderstackSdkUrl: string; + rudderstackV3SdkUrl: string; rudderstackConfigUrl: string; rudderstackIntegrationsUrl: string; applicationInsightsConnectionString: string; diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 17fbbef447a..18cce14f236 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -224,6 +224,7 @@ export class GrafanaBootConfig { rudderstackWriteKey?: string; rudderstackDataPlaneUrl?: string; rudderstackSdkUrl?: string; + rudderstackV3SdkUrl?: string; rudderstackConfigUrl?: string; rudderstackIntegrationsUrl?: string; analyticsConsoleReporting = false; diff --git a/pkg/api/dtos/frontend_settings.go b/pkg/api/dtos/frontend_settings.go index 218817c6801..d140c328242 100644 --- a/pkg/api/dtos/frontend_settings.go +++ b/pkg/api/dtos/frontend_settings.go @@ -200,6 +200,7 @@ type FrontendSettingsDTO struct { RudderstackWriteKey string `json:"rudderstackWriteKey"` RudderstackDataPlaneUrl string `json:"rudderstackDataPlaneUrl"` RudderstackSdkUrl string `json:"rudderstackSdkUrl"` + RudderstackV3SdkUrl string `json:"rudderstackV3SdkUrl"` RudderstackConfigUrl string `json:"rudderstackConfigUrl"` RudderstackIntegrationsUrl string `json:"rudderstackIntegrationsUrl"` diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index c105b5ee829..b57262087e5 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -229,6 +229,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro RudderstackWriteKey: hs.Cfg.RudderstackWriteKey, RudderstackDataPlaneUrl: hs.Cfg.RudderstackDataPlaneURL, RudderstackSdkUrl: hs.Cfg.RudderstackSDKURL, + RudderstackV3SdkUrl: hs.Cfg.RudderstackV3SDKURL, RudderstackConfigUrl: hs.Cfg.RudderstackConfigURL, RudderstackIntegrationsUrl: hs.Cfg.RudderstackIntegrationsURL, AnalyticsConsoleReporting: hs.Cfg.FrontendAnalyticsConsoleReporting, diff --git a/pkg/services/frontend/frontend_settings.go b/pkg/services/frontend/frontend_settings.go index f6cf9855b2d..d9476c8ff40 100644 --- a/pkg/services/frontend/frontend_settings.go +++ b/pkg/services/frontend/frontend_settings.go @@ -32,6 +32,7 @@ type FSFrontendSettings struct { RudderstackWriteKey string `json:"rudderstackWriteKey,omitempty"` RudderstackDataPlaneUrl string `json:"rudderstackDataPlaneUrl,omitempty"` RudderstackSdkUrl string `json:"rudderstackSdkUrl,omitempty"` + RudderstackV3SdkUrl string `json:"rudderstackV3SdkUrl,omitempty"` RudderstackConfigUrl string `json:"rudderstackConfigUrl,omitempty"` RudderstackIntegrationsUrl string `json:"rudderstackIntegrationsUrl,omitempty"` diff --git a/pkg/services/frontend/index.go b/pkg/services/frontend/index.go index e87ca894d20..25704f1ab6d 100644 --- a/pkg/services/frontend/index.go +++ b/pkg/services/frontend/index.go @@ -94,6 +94,7 @@ func NewIndexProvider(cfg *setting.Cfg, assetsManifest dtos.EntryPointAssets, li RudderstackDataPlaneUrl: cfg.RudderstackDataPlaneURL, RudderstackIntegrationsUrl: cfg.RudderstackIntegrationsURL, RudderstackSdkUrl: cfg.RudderstackSDKURL, + RudderstackV3SdkUrl: cfg.RudderstackV3SDKURL, RudderstackWriteKey: cfg.RudderstackWriteKey, TrustedTypesDefaultPolicyEnabled: (cfg.CSPEnabled && strings.Contains(cfg.CSPTemplate, "require-trusted-types-for")) || (cfg.CSPReportOnlyEnabled && strings.Contains(cfg.CSPReportOnlyTemplate, "require-trusted-types-for")), VerifyEmailEnabled: cfg.VerifyEmailEnabled, diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 3d202b20cec..62b9332581f 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -414,6 +414,7 @@ type Cfg struct { RudderstackDataPlaneURL string RudderstackWriteKey string RudderstackSDKURL string + RudderstackV3SDKURL string RudderstackConfigURL string RudderstackIntegrationsURL string IntercomSecret string @@ -1284,6 +1285,7 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error { cfg.RudderstackWriteKey = analytics.Key("rudderstack_write_key").String() cfg.RudderstackDataPlaneURL = analytics.Key("rudderstack_data_plane_url").String() cfg.RudderstackSDKURL = analytics.Key("rudderstack_sdk_url").String() + cfg.RudderstackV3SDKURL = analytics.Key("rudderstack_v3_sdk_url").String() cfg.RudderstackConfigURL = analytics.Key("rudderstack_config_url").String() cfg.RudderstackIntegrationsURL = analytics.Key("rudderstack_integrations_url").String() cfg.IntercomSecret = analytics.Key("intercom_secret").String() diff --git a/public/app/core/services/echo/init.ts b/public/app/core/services/echo/init.ts index 5e49e72422a..9fce1a539ef 100644 --- a/public/app/core/services/echo/init.ts +++ b/public/app/core/services/echo/init.ts @@ -146,17 +146,15 @@ async function initRudderstackBackend() { return; } - // this will need to be updated when rudderstackSdkV3Url is added - // Desired logic: if only one of the sdk urls is provided, use respective code + // Logic: if only one of the sdk urls is provided, use respective code // otherwise defer to the feature toggle. - const fakeConfigRudderstackSdkV3Url: string | undefined = undefined; const hasOldSdkUrl = Boolean(config.rudderstackSdkUrl); - const hasNewSdkUrl = Boolean(fakeConfigRudderstackSdkV3Url); - const onlyOneConfigURLSet = hasOldSdkUrl !== hasNewSdkUrl; - const useNewRudderstack = onlyOneConfigURLSet ? hasNewSdkUrl : config.featureToggles.rudderstackUpgrade; + const hasNewSdkUrl = Boolean(config.rudderstackV3SdkUrl); + const onlyOneSdkUrlSet = hasOldSdkUrl !== hasNewSdkUrl; + const useNewRudderstack = onlyOneSdkUrlSet ? hasNewSdkUrl : config.featureToggles.rudderstackUpgrade; - const configUrl = useNewRudderstack ? fakeConfigRudderstackSdkV3Url : config.rudderstackSdkUrl; + const sdkUrl = useNewRudderstack ? config.rudderstackV3SdkUrl : config.rudderstackSdkUrl; const modulePromise = useNewRudderstack ? import('./backends/analytics/RudderstackV3Backend') @@ -168,8 +166,8 @@ async function initRudderstackBackend() { writeKey: config.rudderstackWriteKey, dataPlaneUrl: config.rudderstackDataPlaneUrl, user: contextSrv.user, - sdkUrl: config.rudderstackSdkUrl, - configUrl: configUrl, + sdkUrl, + configUrl: config.rudderstackConfigUrl, integrationsUrl: config.rudderstackIntegrationsUrl, buildInfo: config.buildInfo, }) From 05fd304dbd2be526ac96c29349f361d600144d29 Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Thu, 18 Dec 2025 11:58:21 -0700 Subject: [PATCH 033/229] Dashboards: AdHoc and GroupBy wrapper (#115124) * wip; DrilldownControls * use wrapper so that drilldown controls wrap inline * keep labels on top when input expands vertically * add clear all button * add collapsible prop * i18n * Increase maxWidth for adhoc * bump scenes for testing * fix * remove clear all button * use new feature toggle; pass collapsible in v2 * update variable controls to use new feature flag * cleanup * wip (#115441) * wip * fix * update wrapping on smaller screens --------- Co-authored-by: Haris Rozajac * Filter out variables that are not in inControlsMenu * filter out inControlsMenu vars, not hidden ones * canary scenes * fix * cleanup * canary scenes * pass wideInput to groupby based on ff * update var name and bump scenes * bump scenes * yarn lock --------- Co-authored-by: Victor Marin --- package.json | 4 +- .../src/types/featureToggles.gen.ts | 4 + pkg/services/featuremgmt/registry.go | 7 ++ pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/featuremgmt/toggles_gen.json | 13 +++ .../scene/DashboardControls.tsx | 97 +++++++++++++++++++ .../scene/DrilldownControls.tsx | 77 +++++++++++++++ .../scene/VariableControls.tsx | 23 ++++- .../transformSaveModelSchemaV2ToScene.ts | 2 + .../dashboard-scene/utils/variables.ts | 2 + yarn.lock | 22 ++--- 12 files changed, 240 insertions(+), 16 deletions(-) create mode 100644 public/app/features/dashboard-scene/scene/DrilldownControls.tsx diff --git a/package.json b/package.json index 37dc6ff745c..73dec9dbc90 100644 --- a/package.json +++ b/package.json @@ -295,8 +295,8 @@ "@grafana/plugin-ui": "^0.11.1", "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", - "@grafana/scenes": "^6.51.0", - "@grafana/scenes-react": "^6.51.0", + "@grafana/scenes": "6.52.0", + "@grafana/scenes-react": "6.52.0", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index b91161d966c..e32ce90bcc5 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -499,6 +499,10 @@ export interface FeatureToggles { */ newDashboardWithFiltersAndGroupBy?: boolean; /** + * Wraps the ad hoc and group by variables in a single wrapper, with all other variables below it + */ + dashboardAdHocAndGroupByWrapper?: boolean; + /** * Updates CloudWatch label parsing to be more accurate * @default true */ diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index d8a46601ad6..bd2707f3356 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -820,6 +820,13 @@ var ( Owner: grafanaDashboardsSquad, HideFromDocs: true, }, + { + Name: "dashboardAdHocAndGroupByWrapper", + Description: "Wraps the ad hoc and group by variables in a single wrapper, with all other variables below it", + Stage: FeatureStageExperimental, + Owner: grafanaDashboardsSquad, + HideFromDocs: true, + }, { Name: "cloudWatchNewLabelParsing", Description: "Updates CloudWatch label parsing to be more accurate", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 3fbc019bff1..095350eb0c8 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -113,6 +113,7 @@ scopeFilters,experimental,@grafana/dashboards-squad,false,false,false oauthRequireSubClaim,experimental,@grafana/identity-access-team,false,false,false refreshTokenRequired,experimental,@grafana/identity-access-team,false,false,false newDashboardWithFiltersAndGroupBy,experimental,@grafana/dashboards-squad,false,false,false +dashboardAdHocAndGroupByWrapper,experimental,@grafana/dashboards-squad,false,false,false cloudWatchNewLabelParsing,GA,@grafana/aws-datasources,false,false,false disableNumericMetricsSortingInExpressions,experimental,@grafana/oss-big-tent,false,true,false grafanaManagedRecordingRules,experimental,@grafana/alerting-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 020e0bce893..fd77326e8b0 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -339,6 +339,10 @@ const ( // Enables filters and group by variables on all new dashboards. Variables are added only if default data source supports filtering. FlagNewDashboardWithFiltersAndGroupBy = "newDashboardWithFiltersAndGroupBy" + // FlagDashboardAdHocAndGroupByWrapper + // Wraps the ad hoc and group by variables in a single wrapper, with all other variables below it + FlagDashboardAdHocAndGroupByWrapper = "dashboardAdHocAndGroupByWrapper" + // FlagCloudWatchNewLabelParsing // Updates CloudWatch label parsing to be more accurate FlagCloudWatchNewLabelParsing = "cloudWatchNewLabelParsing" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 866f8a78881..0de187cce52 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -922,6 +922,19 @@ "frontend": true } }, + { + "metadata": { + "name": "dashboardAdHocAndGroupByWrapper", + "resourceVersion": "1765841806645", + "creationTimestamp": "2025-12-15T23:36:46Z" + }, + "spec": { + "description": "Wraps the ad hoc and group by variables in a single wrapper, with all other variables below it", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "hideFromDocs": true + } + }, { "metadata": { "name": "dashboardDisableSchemaValidationV1", diff --git a/public/app/features/dashboard-scene/scene/DashboardControls.tsx b/public/app/features/dashboard-scene/scene/DashboardControls.tsx index 32f7d5289f8..a44fd918393 100644 --- a/public/app/features/dashboard-scene/scene/DashboardControls.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardControls.tsx @@ -16,6 +16,7 @@ import { SceneObjectUrlSyncConfig, SceneObjectUrlValues, CancelActivationHandler, + sceneUtils, } from '@grafana/scenes'; import { Box, Button, useStyles2 } from '@grafana/ui'; import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; @@ -27,6 +28,7 @@ import { getDashboardSceneFor } from '../utils/utils'; import { DashboardDataLayerControls } from './DashboardDataLayerControls'; import { DashboardLinksControls } from './DashboardLinksControls'; import { DashboardScene } from './DashboardScene'; +import { DrilldownControls } from './DrilldownControls'; import { VariableControls } from './VariableControls'; import { DashboardControlsButton } from './dashboard-controls-menu/DashboardControlsMenuButton'; import { hasDashboardControls, useHasDashboardControls } from './dashboard-controls-menu/utils'; @@ -151,11 +153,63 @@ function DashboardControlsRenderer({ model }: SceneComponentProps v.state.hide !== VariableHide.inControlsMenu); + const adHocVar = visibleVariables.find((v) => sceneUtils.isAdHocVariable(v)); + const groupByVar = visibleVariables.find((v) => sceneUtils.isGroupByVariable(v)); + const useUnifiedDrilldownUI = config.featureToggles.dashboardAdHocAndGroupByWrapper && adHocVar && groupByVar; + if (!model.hasControls()) { // To still have spacing when no controls are rendered return {renderHiddenVariables(dashboard)}; } + // When dashboardAdHocAndGroupByWrapper is enabled, use the new layout with topRow + if (useUnifiedDrilldownUI) { + return ( +
+
+ {config.featureToggles.scopeFilters && !editPanel && ( + + )} + {!hideVariableControls && ( +
+ +
+ )} +
+ {!hideTimeControls && ( +
+ + +
+ )} + {config.featureToggles.dashboardNewLayouts && ( +
+ +
+ )} +
+
+ {!hideVariableControls && ( + <> + + + + )} + {!hideLinksControls && !editPanel && } + {!hideDashboardControls && hasDashboardControls && } + {editPanel && } + {showDebugger && } +
+ ); + } + + // Original layout when feature toggle is off return (
+
+ +
+
+ +
+
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + drilldownRow: css({ + display: 'flex', + flexWrap: 'nowrap', + [theme.breakpoints.down('xl')]: { + flexWrap: 'wrap', + }, + gap: theme.spacing(1), + width: '100%', + }), + adHocContainer: css({ + flex: '7 1 0%', // 70% of available space + minWidth: 0, + [theme.breakpoints.down('xl')]: { + // Force full width, causing groupBy to wrap + flex: '1 1 100%', + }, + display: 'flex', + // Make the wrapper and its children take full width + '& > div': { + alignItems: 'flex-start', + width: '100%', + flex: 1, + }, + }), + groupByContainer: css({ + flex: '3 1 0%', // 30% of available space + minWidth: 0, + display: 'flex', + // Make the wrapper and its children take full width + '& > div': { + alignItems: 'flex-start', + width: '100%', + flex: 1, + }, + [theme.breakpoints.down('sm')]: { + minWidth: '200px', + }, + }), + clearAllButton: css({ + alignSelf: 'flex-start', + fontSize: theme.typography.bodySmall.fontSize, + padding: theme.spacing(0.5, 0.5), + marginBottom: theme.spacing(1), + }), +}); diff --git a/public/app/features/dashboard-scene/scene/VariableControls.tsx b/public/app/features/dashboard-scene/scene/VariableControls.tsx index 03d23712c7b..73dd2614472 100644 --- a/public/app/features/dashboard-scene/scene/VariableControls.tsx +++ b/public/app/features/dashboard-scene/scene/VariableControls.tsx @@ -20,13 +20,30 @@ import { AddVariableButton } from './VariableControlsAddButton'; export function VariableControls({ dashboard }: { dashboard: DashboardScene }) { const { variables } = sceneGraph.getVariables(dashboard)!.useState(); + // Get visible variables for drilldown layout + const visibleVariables = variables.filter((v) => v.state.hide !== VariableHide.inControlsMenu); + + const adHocVar = visibleVariables.find((v) => sceneUtils.isAdHocVariable(v)); + const groupByVar = visibleVariables.find((v) => sceneUtils.isGroupByVariable(v)); + + const hasDrilldownControls = config.featureToggles.dashboardAdHocAndGroupByWrapper && adHocVar && groupByVar; + + const restVariables = visibleVariables.filter( + (v) => v.state.name !== adHocVar?.state.name && v.state.name !== groupByVar?.state.name + ); + + // Variables to render (exclude adhoc/groupby when drilldown controls are shown in top row) + const variablesToRender = hasDrilldownControls + ? restVariables.filter((v) => v.state.hide !== VariableHide.inControlsMenu) + : variables.filter((v) => v.state.hide !== VariableHide.inControlsMenu); + return ( <> - {variables - .filter((v) => v.state.hide !== VariableHide.inControlsMenu) - .map((variable) => ( + {variablesToRender.length > 0 && + variablesToRender.map((variable) => ( ))} + {config.featureToggles.dashboardNewLayouts ? : null} ); diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts index ff3927d3814..f343cbce00d 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts @@ -336,6 +336,7 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S supportsMultiValueOperators: Boolean( getDataSourceSrv().getInstanceSettings({ type: ds?.type })?.meta.multiValueFilterOperators ), + collapsible: config.featureToggles.dashboardAdHocAndGroupByWrapper, }; if (variable.spec.allowCustomValue !== undefined) { adhocVariableState.allowCustomValue = variable.spec.allowCustomValue; @@ -460,6 +461,7 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S skipUrlSync: variable.spec.skipUrlSync, isMulti: variable.spec.multi, hide: transformVariableHideToEnumV1(variable.spec.hide), + wideInput: config.featureToggles.dashboardAdHocAndGroupByWrapper, drilldownRecommendationsEnabled: config.featureToggles.drilldownRecommendations, // @ts-expect-error defaultOptions: variable.options, diff --git a/public/app/features/dashboard-scene/utils/variables.ts b/public/app/features/dashboard-scene/utils/variables.ts index 2f262781b00..cd09e094845 100644 --- a/public/app/features/dashboard-scene/utils/variables.ts +++ b/public/app/features/dashboard-scene/utils/variables.ts @@ -164,6 +164,7 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode useQueriesAsFilterForOptions: true, drilldownRecommendationsEnabled: config.featureToggles.drilldownRecommendations, layout: config.featureToggles.newFiltersUI ? 'combobox' : undefined, + collapsible: config.featureToggles.dashboardAdHocAndGroupByWrapper, supportsMultiValueOperators: Boolean( getDataSourceSrv().getInstanceSettings({ type: variable.datasource?.type })?.meta.multiValueFilterOperators ), @@ -288,6 +289,7 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode text: variable.current?.text || [], skipUrlSync: variable.skipUrlSync, hide: variable.hide, + wideInput: config.featureToggles.dashboardAdHocAndGroupByWrapper, // @ts-expect-error defaultOptions: variable.options, defaultValue: variable.defaultValue, diff --git a/yarn.lock b/yarn.lock index a60a9c08912..64561ce4dca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3604,11 +3604,11 @@ __metadata: languageName: unknown linkType: soft -"@grafana/scenes-react@npm:^6.51.0": - version: 6.51.0 - resolution: "@grafana/scenes-react@npm:6.51.0" +"@grafana/scenes-react@npm:6.52.0": + version: 6.52.0 + resolution: "@grafana/scenes-react@npm:6.52.0" dependencies: - "@grafana/scenes": "npm:6.51.0" + "@grafana/scenes": "npm:6.52.0" lru-cache: "npm:^10.2.2" react-use: "npm:^17.4.0" peerDependencies: @@ -3620,7 +3620,7 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/14acdfe5220e67e7450780320b779e2e4a255995d55f0c82eb0d25933e72598e54826df0a8beee05591efe01a91ddab840483fea3bb828bd5925c3f0b44b8d17 + checksum: 10/7f121bcc4fd50f525c7c3457666ad3a32b04783d322d6715aedb6119538f911d0ec265c9c5b49a80478c1deb99286d36d003399a8831f76b6c483f4458b4ce8b languageName: node linkType: hard @@ -3650,9 +3650,9 @@ __metadata: languageName: node linkType: hard -"@grafana/scenes@npm:6.51.0, @grafana/scenes@npm:^6.51.0": - version: 6.51.0 - resolution: "@grafana/scenes@npm:6.51.0" +"@grafana/scenes@npm:6.52.0": + version: 6.52.0 + resolution: "@grafana/scenes@npm:6.52.0" dependencies: "@floating-ui/react": "npm:^0.26.16" "@leeoniya/ufuzzy": "npm:^1.0.16" @@ -3672,7 +3672,7 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/4e4f43babe786ff729d58b7636182df57c58ce40c13b56036f725c070e0cf597cbe52aaa0f811184b8d42d8d1f9a32679695471d410f883051b09da44f8bf36a + checksum: 10/e52e0fb83396776c6cb79f8ac6a8aad0799eb2ccce9d0139f5734a49c3add7a1e3b97f14e0142c95b2bceee3ed8fa97b675b9b94c02382ecd683f470d06ef145 languageName: node linkType: hard @@ -19508,8 +19508,8 @@ __metadata: "@grafana/plugin-ui": "npm:^0.11.1" "@grafana/prometheus": "workspace:*" "@grafana/runtime": "workspace:*" - "@grafana/scenes": "npm:^6.51.0" - "@grafana/scenes-react": "npm:^6.51.0" + "@grafana/scenes": "npm:6.52.0" + "@grafana/scenes-react": "npm:6.52.0" "@grafana/schema": "workspace:*" "@grafana/sql": "workspace:*" "@grafana/test-utils": "workspace:*" From 39c562a911a0a7dc3663223adbcd92cc33fb18be Mon Sep 17 00:00:00 2001 From: Denis Vodopianov Date: Thu, 18 Dec 2025 22:46:32 +0100 Subject: [PATCH 034/229] Revert: chore: a drop-in replacement for FeatureToggles.IsEnabledGlobally in app settings (#115593) * Revert "chore: a drop-in replacement for FeatureToggles.IsEnabledGlobally in app settings (#113449)" This reverts commit 26ce2c09d78706e4235cc378d67da9f99c6ba99d. * Change FeatureToggles.IsEnabledGlobally deprecation message --- pkg/registry/apps/apps.go | 2 +- pkg/services/featuremgmt/models.go | 4 +--- pkg/setting/setting.go | 5 ----- pkg/setting/startup_setting.go | 14 -------------- 4 files changed, 2 insertions(+), 23 deletions(-) delete mode 100644 pkg/setting/startup_setting.go diff --git a/pkg/registry/apps/apps.go b/pkg/registry/apps/apps.go index 77c96fd437c..a1ec8aafd65 100644 --- a/pkg/registry/apps/apps.go +++ b/pkg/registry/apps/apps.go @@ -76,7 +76,7 @@ func ProvideAppInstallers( if features.IsEnabledGlobally(featuremgmt.FlagKubernetesLogsDrilldown) { installers = append(installers, logsdrilldownAppInstaller) } - //nolint:staticcheck // not yet migrated to OpenFeature + //nolint:staticcheck if features.IsEnabledGlobally(featuremgmt.FlagKubernetesAnnotations) { installers = append(installers, annotationAppInstaller) } diff --git a/pkg/services/featuremgmt/models.go b/pkg/services/featuremgmt/models.go index 304d17ee075..d59dff63c37 100644 --- a/pkg/services/featuremgmt/models.go +++ b/pkg/services/featuremgmt/models.go @@ -23,9 +23,7 @@ type FeatureToggles interface { // a full server restart for a change to take place. // // Deprecated: FeatureToggles.IsEnabledGlobally is deprecated and will be removed in a future release. - // Toggles that must be reliably evaluated at the service startup should be - // changed to settings (see setting.StartupSettings), and/or removed entirely. - // For app registration please use `grafana-apiserver.runtime_config` in settings.ini + // Toggles that must be reliably evaluated at the service startup should be changed to settings and/or removed entirely. IsEnabledGlobally(flag string) bool // Get the enabled flags -- this *may* also include disabled flags (with value false) diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 62b9332581f..00bcdabd88d 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -511,9 +511,6 @@ type Cfg struct { // Query history QueryHistoryEnabled bool - // StartupSettings settings - StartupSettings StartupSettings - // Open feature settings OpenFeature OpenFeatureSettings @@ -1476,8 +1473,6 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error { // unified storage config cfg.setUnifiedStorageConfig() - // app platform config - cfg.readStartupSettingsSection() return nil } diff --git a/pkg/setting/startup_setting.go b/pkg/setting/startup_setting.go deleted file mode 100644 index 8b2c2fa6362..00000000000 --- a/pkg/setting/startup_setting.go +++ /dev/null @@ -1,14 +0,0 @@ -package setting - -type StartupSettings struct { - KubernetesAnnotationsAppEnabled bool -} - -func (cfg *Cfg) readStartupSettingsSection() { - settings := StartupSettings{} - - startupSettingsSection := cfg.Raw.Section("startup_settings") - settings.KubernetesAnnotationsAppEnabled = startupSettingsSection.Key("annotations_app_enabled").MustBool(false) - - cfg.StartupSettings = settings -} From 37c1e3fb02f46f315ea53b301fe089d86374419f Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Thu, 18 Dec 2025 15:11:09 -0700 Subject: [PATCH 035/229] Dashboard Schema v1beta1 to v2alpha1: Preserve string template variable datasource references in query variables (#115516) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Dashboard migration: preserve legacy string datasource references Fix v1beta1 → v2alpha1 conversion to handle legacy string datasource references in QueryVariable, AdhocVariable, and GroupByVariable. Previously, string datasource references (both template variables like "$datasource" and direct names/UIDs like "prometheus") were being dropped during conversion, causing variable chaining to break. The frontend's DatasourceSrv.getInstanceSettings() already handles string references by trying uid → name → id lookup at runtime, so we preserve the string in the uid field and let the frontend resolve it. * trigger frontend ci tests when dashboard migration code changes * v1: if string convert to DS ref * Update migration testdata to fix template variable datasource references * update --- .github/actions/change-detection/action.yml | 1 + ....mimir_rollout_debugging.v42.v0alpha1.json | 8 ++- ....mimir_rollout_debugging.v42.v2alpha1.json | 12 ++++- ...5.mimir_rollout_debugging.v42.v2beta1.json | 10 +++- .../conversion/v0alpha1_to_v1beta1.go | 53 +++++++++++++++++++ .../conversion/v1beta1_to_v2alpha1.go | 12 +++++ .../input/v15.mimir_rollout_debugging.json | 8 ++- .../v15.mimir_rollout_debugging.v42.json | 8 ++- .../v15.mimir_rollout_debugging.v15.json | 8 ++- 9 files changed, 108 insertions(+), 12 deletions(-) diff --git a/.github/actions/change-detection/action.yml b/.github/actions/change-detection/action.yml index 2c6b46606f4..863cab646b9 100644 --- a/.github/actions/change-detection/action.yml +++ b/.github/actions/change-detection/action.yml @@ -95,6 +95,7 @@ runs: - 'nx.json' - 'tsconfig.json' - '.yarn/**' + - 'apps/dashboard/pkg/migration/**' - '${{ inputs.self }}' e2e: - 'e2e/**' diff --git a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v15.mimir_rollout_debugging.v42.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v15.mimir_rollout_debugging.v42.v0alpha1.json index 6c259253e55..35cc948844c 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v15.mimir_rollout_debugging.v42.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v15.mimir_rollout_debugging.v42.v0alpha1.json @@ -743,7 +743,9 @@ "text": "prod", "value": "prod" }, - "datasource": "$datasource", + "datasource": { + "uid": "$datasource" + }, "hide": 0, "includeAll": true, "label": "cluster", @@ -764,7 +766,9 @@ "text": "prod", "value": "prod" }, - "datasource": "$datasource", + "datasource": { + "uid": "$datasource" + }, "hide": 0, "includeAll": false, "label": "namespace", diff --git a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v15.mimir_rollout_debugging.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v15.mimir_rollout_debugging.v42.v2alpha1.json index 8d340c0f65e..26945033c4c 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v15.mimir_rollout_debugging.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v15.mimir_rollout_debugging.v42.v2alpha1.json @@ -961,8 +961,12 @@ "hide": "dontHide", "refresh": "onDashboardLoad", "skipUrlSync": false, + "datasource": { + "type": "", + "uid": "$datasource" + }, "query": { - "kind": "prometheus", + "kind": "", "spec": { "__legacyStringValue": "label_values(up, job)" } @@ -988,8 +992,12 @@ "hide": "dontHide", "refresh": "onDashboardLoad", "skipUrlSync": false, + "datasource": { + "type": "", + "uid": "$datasource" + }, "query": { - "kind": "prometheus", + "kind": "", "spec": { "__legacyStringValue": "label_values(up{job=~\"$cluster\"}, instance)" } diff --git a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v15.mimir_rollout_debugging.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v15.mimir_rollout_debugging.v42.v2beta1.json index 8070895766a..344b0ef564a 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v15.mimir_rollout_debugging.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v15.mimir_rollout_debugging.v42.v2beta1.json @@ -978,8 +978,11 @@ "skipUrlSync": false, "query": { "kind": "DataQuery", - "group": "prometheus", + "group": "", "version": "v0", + "datasource": { + "name": "$datasource" + }, "spec": { "__legacyStringValue": "label_values(up, job)" } @@ -1007,8 +1010,11 @@ "skipUrlSync": false, "query": { "kind": "DataQuery", - "group": "prometheus", + "group": "", "version": "v0", + "datasource": { + "name": "$datasource" + }, "spec": { "__legacyStringValue": "label_values(up{job=~\"$cluster\"}, instance)" } diff --git a/apps/dashboard/pkg/migration/conversion/v0alpha1_to_v1beta1.go b/apps/dashboard/pkg/migration/conversion/v0alpha1_to_v1beta1.go index 2154a26e3a2..d0679c76e63 100644 --- a/apps/dashboard/pkg/migration/conversion/v0alpha1_to_v1beta1.go +++ b/apps/dashboard/pkg/migration/conversion/v0alpha1_to_v1beta1.go @@ -2,6 +2,7 @@ package conversion import ( "context" + "strings" "k8s.io/apimachinery/pkg/conversion" "k8s.io/apiserver/pkg/endpoints/request" @@ -79,5 +80,57 @@ func ConvertDashboard_V0_to_V1beta1(in *dashv0.Dashboard, out *dashv1.Dashboard, return schemaversion.NewMigrationError(err.Error(), schemaversion.GetSchemaVersion(in.Spec.Object), schemaversion.LATEST_VERSION, "Convert_V0_to_V1") } + // Normalize template variable datasources from string to object format + // This handles legacy dashboards where query variables have datasource: "$datasource" (string) + // instead of datasource: { uid: "$datasource" } (object) + // our migration pipeline in v36 doesn't address because this was not addressed historically + // in DashboardMigrator - see public/app/features/dashboard/state/DashboardMigrator.ts#L607 + // Which means that we have schemaVersion: 42 dashboards where datasource variable references are still strings + normalizeTemplateVariableDatasources(out.Spec.Object) + return nil } + +// normalizeTemplateVariableDatasources converts template variable string datasources to object format. +// Legacy dashboards may have query variables with datasource: "$datasource" (string). +// This normalizes them to datasource: { uid: "$datasource" } for consistent V1→V2 conversion. +func normalizeTemplateVariableDatasources(dashboard map[string]interface{}) { + templating, ok := dashboard["templating"].(map[string]interface{}) + if !ok { + return + } + + list, ok := templating["list"].([]interface{}) + if !ok { + return + } + + for _, variable := range list { + varMap, ok := variable.(map[string]interface{}) + if !ok { + continue + } + + varType, _ := varMap["type"].(string) + if varType != "query" { + continue + } + + ds := varMap["datasource"] + if dsStr, ok := ds.(string); ok && isTemplateVariableRef(dsStr) { + // Convert string template variable reference to object format + varMap["datasource"] = map[string]interface{}{ + "uid": dsStr, + } + } + } +} + +// isTemplateVariableRef checks if a string is a Grafana template variable reference. +// Template variables can be in the form: $varname or ${varname} +func isTemplateVariableRef(s string) bool { + if s == "" { + return false + } + return strings.HasPrefix(s, "$") || strings.HasPrefix(s, "${") +} diff --git a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go index 47c12a6ac94..92c5d937fe7 100644 --- a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go +++ b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go @@ -1185,6 +1185,10 @@ func buildQueryVariable(ctx context.Context, varMap map[string]interface{}, comm // If no UID and no type, use default datasourceType = getDefaultDatasourceType(ctx, dsIndexProvider) } + } else if dsStr, ok := datasource.(string); ok && isTemplateVariable(dsStr) { + // Handle datasource variable reference (e.g., "$datasource") + // Only process template variables - other string values are not supported in V2 format + datasourceUID = dsStr } else { datasourceType = getDefaultDatasourceType(ctx, dsIndexProvider) } @@ -1532,6 +1536,10 @@ func buildAdhocVariable(ctx context.Context, varMap map[string]interface{}, comm // If no UID and no type, use default datasourceType = getDefaultDatasourceType(ctx, dsIndexProvider) } + } else if dsStr, ok := datasource.(string); ok && isTemplateVariable(dsStr) { + // Handle datasource variable reference (e.g., "$datasource") + // Only process template variables - other string values are not supported in V2 format + datasourceUID = dsStr } else { datasourceType = getDefaultDatasourceType(ctx, dsIndexProvider) } @@ -1709,6 +1717,10 @@ func buildGroupByVariable(ctx context.Context, varMap map[string]interface{}, co // Resolve Grafana datasource UID when type is "datasource" and UID is empty datasourceUID = resolveGrafanaDatasourceUID(datasourceType, datasourceUID) + } else if dsStr, ok := datasource.(string); ok && isTemplateVariable(dsStr) { + // Handle datasource variable reference (e.g., "$datasource") + // Only process template variables - other string values are not supported in V2 format + datasourceUID = dsStr } else { datasourceType = getDefaultDatasourceType(ctx, dsIndexProvider) } diff --git a/apps/dashboard/pkg/migration/testdata/input/v15.mimir_rollout_debugging.json b/apps/dashboard/pkg/migration/testdata/input/v15.mimir_rollout_debugging.json index dc0ca4e5845..1cc39838eab 100644 --- a/apps/dashboard/pkg/migration/testdata/input/v15.mimir_rollout_debugging.json +++ b/apps/dashboard/pkg/migration/testdata/input/v15.mimir_rollout_debugging.json @@ -654,7 +654,9 @@ "text": "prod", "value": "prod" }, - "datasource": "$datasource", + "datasource": { + "uid": "$datasource" + }, "hide": 0, "includeAll": true, "label": "cluster", @@ -677,7 +679,9 @@ "text": "prod", "value": "prod" }, - "datasource": "$datasource", + "datasource": { + "uid": "$datasource" + }, "hide": 0, "includeAll": false, "label": "namespace", diff --git a/apps/dashboard/pkg/migration/testdata/output/latest_version/v15.mimir_rollout_debugging.v42.json b/apps/dashboard/pkg/migration/testdata/output/latest_version/v15.mimir_rollout_debugging.v42.json index 6f507f5bc47..1195b2bec63 100644 --- a/apps/dashboard/pkg/migration/testdata/output/latest_version/v15.mimir_rollout_debugging.v42.json +++ b/apps/dashboard/pkg/migration/testdata/output/latest_version/v15.mimir_rollout_debugging.v42.json @@ -737,7 +737,9 @@ "text": "prod", "value": "prod" }, - "datasource": "$datasource", + "datasource": { + "uid": "$datasource" + }, "hide": 0, "includeAll": true, "label": "cluster", @@ -758,7 +760,9 @@ "text": "prod", "value": "prod" }, - "datasource": "$datasource", + "datasource": { + "uid": "$datasource" + }, "hide": 0, "includeAll": false, "label": "namespace", diff --git a/apps/dashboard/pkg/migration/testdata/output/single_version/v15.mimir_rollout_debugging.v15.json b/apps/dashboard/pkg/migration/testdata/output/single_version/v15.mimir_rollout_debugging.v15.json index dd9ec62ed85..30186560450 100644 --- a/apps/dashboard/pkg/migration/testdata/output/single_version/v15.mimir_rollout_debugging.v15.json +++ b/apps/dashboard/pkg/migration/testdata/output/single_version/v15.mimir_rollout_debugging.v15.json @@ -717,7 +717,9 @@ "text": "prod", "value": "prod" }, - "datasource": "$datasource", + "datasource": { + "uid": "$datasource" + }, "hide": 0, "includeAll": true, "label": "cluster", @@ -739,7 +741,9 @@ "text": "prod", "value": "prod" }, - "datasource": "$datasource", + "datasource": { + "uid": "$datasource" + }, "hide": 0, "includeAll": false, "label": "namespace", From 72e1f1e5461cdde23a888aea9d101702bfcb701a Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Thu, 18 Dec 2025 16:45:00 -0600 Subject: [PATCH 036/229] Heatmap: Support for linear y axis (#113337) * wip * boop * Base factor on data * Add some basic option control * Remove old comments * Add feature flag * Apply feature flag to axis options * Turn factor calculation into exported function * Simplify bucket factor function * Clarify comments * Fix cell sizing of pre-bucketed heatmaps with log * Remove unnecessary category change * Consolidate editor for calculate from data no * Update bucket function sanity checks * Wire up scale config from yBucketScale * Hide bucket controls for heatmap cells * Fix splits * Add test coverage * Fix failing test * Add basic util test coverage * Fix tooltip for legacy in linear * Fix y bucket option width to be consistent * Hide tick alignment for explicit scale modes * Clarify comment * Make sure units are passed properly for linear * Remove null assertion operator * Clean up nested ternary * Add type protection to scaleLog * Remove repeated code for ySize calcs * Remove ternary for scaleDistribution * Add test coverage for YBucketScaleEditor * Add isHeatmapSparse function to tooltip utils * Create calculateYSizeDivisor util function * Fix y axis min and max options and extend to log * Add toLogBase test coverage * Create applyExplicitMinMax function * Add additional test coverage for scale editor * Run i18n-extract * Update eslint suppressions --------- Co-authored-by: Drew Slobodnjak <60050885+drew08t@users.noreply.github.com> --- eslint-suppressions.json | 2 +- .../src/types/featureToggles.gen.ts | 5 + .../panelcfg/x/HeatmapPanelCfg_types.gen.ts | 4 + pkg/services/featuremgmt/registry.go | 8 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.json | 14 + .../calculateHeatmap/heatmap.test.ts | 327 ++++++++++++++- .../transformers/calculateHeatmap/heatmap.ts | 158 ++++++-- .../plugins/panel/heatmap/HeatmapPanel.tsx | 16 +- .../plugins/panel/heatmap/HeatmapTooltip.tsx | 15 +- .../panel/heatmap/YBucketScaleEditor.test.tsx | 277 +++++++++++++ .../panel/heatmap/YBucketScaleEditor.tsx | 135 +++++++ public/app/plugins/panel/heatmap/module.tsx | 26 +- public/app/plugins/panel/heatmap/panelcfg.cue | 2 + .../app/plugins/panel/heatmap/panelcfg.gen.ts | 4 + .../panel/heatmap/tooltip/utils.test.ts | 47 +++ .../plugins/panel/heatmap/tooltip/utils.ts | 14 +- .../app/plugins/panel/heatmap/utils.test.ts | 375 +++++++++++++++++- public/app/plugins/panel/heatmap/utils.ts | 115 +++++- public/locales/en-US/grafana.json | 13 + 20 files changed, 1481 insertions(+), 77 deletions(-) create mode 100644 public/app/plugins/panel/heatmap/YBucketScaleEditor.test.tsx create mode 100644 public/app/plugins/panel/heatmap/YBucketScaleEditor.tsx create mode 100644 public/app/plugins/panel/heatmap/tooltip/utils.test.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 1cdf9de4c4a..1d0d5a2684c 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -4339,7 +4339,7 @@ }, "public/app/plugins/panel/heatmap/utils.ts": { "@typescript-eslint/consistent-type-assertions": { - "count": 16 + "count": 14 } }, "public/app/plugins/panel/histogram/Histogram.tsx": { diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index e32ce90bcc5..199be6d9c3f 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1169,6 +1169,11 @@ export interface FeatureToggles { */ externalVizSuggestions?: boolean; /** + * Enable Y-axis scale configuration options for pre-bucketed heatmap data (heatmap-rows) + * @default false + */ + heatmapRowsAxisOptions?: boolean; + /** * Restrict PanelChrome contents with overflow: hidden; * @default true */ diff --git a/packages/grafana-schema/src/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen.ts index 1ff4370c9a2..b67a8cf5980 100644 --- a/packages/grafana-schema/src/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen.ts @@ -185,6 +185,10 @@ export interface RowsHeatmapOptions { * Sets the name of the cell when not calculating from data */ value?: string; + /** + * Controls the scale distribution of the y-axis buckets + */ + yBucketScale?: ui.ScaleDistributionConfig; } export interface Options { diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index bd2707f3356..e9a4a8c7caf 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1928,6 +1928,14 @@ var ( Owner: grafanaDatavizSquad, Expression: "false", }, + { + Name: "heatmapRowsAxisOptions", + Description: "Enable Y-axis scale configuration options for pre-bucketed heatmap data (heatmap-rows)", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaDatavizSquad, + Expression: "false", + }, { Name: "preventPanelChromeOverflow", Description: "Restrict PanelChrome contents with overflow: hidden;", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 095350eb0c8..d56aff17bb7 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -262,6 +262,7 @@ pluginInstallAPISync,experimental,@grafana/plugins-platform-backend,false,false, newGauge,experimental,@grafana/dataviz-squad,false,false,true newVizSuggestions,preview,@grafana/dataviz-squad,false,false,true externalVizSuggestions,experimental,@grafana/dataviz-squad,false,false,true +heatmapRowsAxisOptions,experimental,@grafana/dataviz-squad,false,false,true preventPanelChromeOverflow,preview,@grafana/grafana-frontend-platform,false,false,true jaegerEnableGrpcEndpoint,experimental,@grafana/oss-big-tent,false,false,false pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 0de187cce52..a0afcd87626 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1647,6 +1647,20 @@ "codeowner": "@grafana/search-and-storage" } }, + { + "metadata": { + "name": "heatmapRowsAxisOptions", + "resourceVersion": "1765353244400", + "creationTimestamp": "2025-12-10T07:54:04Z" + }, + "spec": { + "description": "Enable Y-axis scale configuration options for pre-bucketed heatmap data (heatmap-rows)", + "stage": "experimental", + "codeowner": "@grafana/dataviz-squad", + "frontend": true, + "expression": "false" + } + }, { "metadata": { "name": "improvedExternalSessionHandling", diff --git a/public/app/features/transformers/calculateHeatmap/heatmap.test.ts b/public/app/features/transformers/calculateHeatmap/heatmap.test.ts index 4155a763d4d..d0a963ee9f7 100644 --- a/public/app/features/transformers/calculateHeatmap/heatmap.test.ts +++ b/public/app/features/transformers/calculateHeatmap/heatmap.test.ts @@ -1,7 +1,7 @@ import { FieldType, toDataFrame } from '@grafana/data'; -import { HeatmapCalculationOptions } from '@grafana/schema'; +import { HeatmapCalculationOptions, HeatmapCellLayout, ScaleDistribution } from '@grafana/schema'; -import { rowsToCellsHeatmap, calculateHeatmapFromData } from './heatmap'; +import { rowsToCellsHeatmap, calculateHeatmapFromData, calculateBucketFactor } from './heatmap'; describe('Heatmap transformer', () => { it('calculate heatmap from input data', async () => { @@ -121,4 +121,327 @@ describe('Heatmap transformer', () => { }) ).toThrowErrorMatchingInlineSnapshot(`"No numeric fields found for heatmap"`); }); + + describe('calculateBucketFactor', () => { + it('calculates ratio from last two buckets for log2 spacing', () => { + const buckets = [1, 2, 4, 8]; + expect(calculateBucketFactor(buckets)).toBe(2); + }); + + it('calculates ratio from last two buckets for log10 spacing', () => { + const buckets = [1, 10, 100, 1000]; + expect(calculateBucketFactor(buckets)).toBe(10); + }); + + it('calculates ratio for non-uniform spacing', () => { + const buckets = [1, 2.5, 6.25]; + expect(calculateBucketFactor(buckets)).toBe(2.5); + }); + + it('returns default factor for single value array', () => { + expect(calculateBucketFactor([5])).toBe(1.5); + }); + + it('returns default factor for empty array', () => { + expect(calculateBucketFactor([])).toBe(1.5); + }); + + it('returns default factor when ratio is not valid expansion (<=1)', () => { + const buckets = [10, 5]; // Descending + expect(calculateBucketFactor(buckets)).toBe(1.5); + }); + + it('returns default factor when ratio contains zero', () => { + const buckets = [0, 5]; + expect(calculateBucketFactor(buckets)).toBe(1.5); + }); + + it('returns default factor when ratio is infinite', () => { + const buckets = [5, Infinity]; + expect(calculateBucketFactor(buckets)).toBe(1.5); + }); + + it('accepts custom default factor', () => { + expect(calculateBucketFactor([5], 3)).toBe(3); + }); + }); + + describe('rowsToCellsHeatmap with linear scale', () => { + it('converts prometheus-style le labels to numeric buckets with linear scale', () => { + const frame = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000] }, + { + name: '1', + type: FieldType.number, + labels: { le: '1' }, + values: [10, 15], + }, + { + name: '10', + type: FieldType.number, + labels: { le: '10' }, + values: [20, 25], + }, + { + name: '100', + type: FieldType.number, + labels: { le: '100' }, + values: [30, 35], + }, + ], + }); + + const heatmap = rowsToCellsHeatmap({ + frame, + yBucketScale: { type: ScaleDistribution.Linear }, + }); + + expect(heatmap.fields[1].name).toBe('yMin'); + expect(heatmap.fields[1].values).toEqual([1, 10, 100, 1, 10, 100]); + }); + + it('converts ge labels to numeric buckets with linear scale', () => { + const frame = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000] }, + { + name: '1', + type: FieldType.number, + labels: { ge: '1' }, + values: [10, 15], + }, + { + name: '10', + type: FieldType.number, + labels: { ge: '10' }, + values: [20, 25], + }, + ], + }); + + const heatmap = rowsToCellsHeatmap({ + frame, + yBucketScale: { type: ScaleDistribution.Linear }, + layout: HeatmapCellLayout.ge, + }); + + expect(heatmap.fields[1].values).toEqual([1, 10, 1, 10]); + expect(heatmap.fields[1].name).toBe('yMin'); // ge layout + }); + + it('generates yMax field for linear scale', () => { + const frame = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1000] }, + { name: '1', type: FieldType.number, values: [10] }, + { name: '2', type: FieldType.number, values: [20] }, + { name: '4', type: FieldType.number, values: [30] }, + ], + }); + + const heatmap = rowsToCellsHeatmap({ + frame, + yBucketScale: { type: ScaleDistribution.Linear }, + }); + + // Should have yMin, yMax, and count fields + expect(heatmap.fields.length).toBe(4); + expect(heatmap.fields[2].name).toBe('yMax'); + expect(heatmap.fields[2].type).toBe('number'); + + // yMax should be [2, 4, 8] (shifted buckets + calculated last bucket) + // Last bucket uses factor 2 (from 2→4) to estimate 4→8 + expect(heatmap.fields[2].values).toEqual([2, 4, 8]); + }); + + it('clears yOrdinalDisplay for linear scale', () => { + const frame = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1000] }, + { name: '1', type: FieldType.number, values: [10] }, + { name: '10', type: FieldType.number, values: [20] }, + ], + }); + + const heatmap = rowsToCellsHeatmap({ + frame, + yBucketScale: { type: ScaleDistribution.Linear }, + }); + + expect(heatmap.meta?.custom?.yOrdinalDisplay).toBeUndefined(); + }); + + it('clears yOrdinalDisplay for log scale', () => { + const frame = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1000] }, + { name: '1', type: FieldType.number, values: [10] }, + { name: '10', type: FieldType.number, values: [20] }, + ], + }); + + const heatmap = rowsToCellsHeatmap({ + frame, + yBucketScale: { type: ScaleDistribution.Log, log: 10 }, + }); + + expect(heatmap.meta?.custom?.yOrdinalDisplay).toBeUndefined(); + }); + + it('clears yOrdinalDisplay for symlog scale', () => { + const frame = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1000] }, + { name: '1', type: FieldType.number, values: [10] }, + { name: '10', type: FieldType.number, values: [20] }, + ], + }); + + const heatmap = rowsToCellsHeatmap({ + frame, + yBucketScale: { type: ScaleDistribution.Symlog, log: 10, linearThreshold: 1 }, + }); + + expect(heatmap.meta?.custom?.yOrdinalDisplay).toBeUndefined(); + }); + + it('preserves yOrdinalDisplay for non-numeric scale (auto/ordinal)', () => { + const frame = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1000] }, + { name: 'low', type: FieldType.number, values: [10] }, + { name: 'high', type: FieldType.number, values: [20] }, + ], + }); + + const heatmap = rowsToCellsHeatmap({ frame }); + + expect(heatmap.meta?.custom?.yOrdinalDisplay).toEqual(['low', 'high']); + }); + + it('sets unit to undefined for linear scale when no unit exists', () => { + const frame = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1000] }, + { name: '1', type: FieldType.number, values: [10] }, + { name: '10', type: FieldType.number, values: [20] }, + ], + }); + + const heatmap = rowsToCellsHeatmap({ + frame, + yBucketScale: { type: ScaleDistribution.Linear }, + }); + + // No unit → expect undefined (not 'short') + expect(heatmap.fields[1].config.unit).toBeUndefined(); + }); + + it('passes through existing unit for linear scale', () => { + const frame = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1000] }, + { name: '1', type: FieldType.number, values: [10], config: { unit: 'ms' } }, + { name: '10', type: FieldType.number, values: [20], config: { unit: 'ms' } }, + ], + }); + + const heatmap = rowsToCellsHeatmap({ + frame, + yBucketScale: { type: ScaleDistribution.Linear }, + }); + + // Existing unit → pass through unchanged + expect(heatmap.fields[1].config.unit).toBe('ms'); + }); + + it('sets unit to short for ordinal scale', () => { + const frame = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1000] }, + { name: 'low', type: FieldType.number, values: [10] }, + { name: 'high', type: FieldType.number, values: [20] }, + ], + }); + + const heatmap = rowsToCellsHeatmap({ frame }); + + expect(heatmap.fields[1].config.unit).toBe('short'); + }); + + it('uses "count" as value field name for linear scale', () => { + const frame = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1000] }, + { name: '1', type: FieldType.number, values: [10] }, + { name: '10', type: FieldType.number, values: [20] }, + ], + }); + + const heatmap = rowsToCellsHeatmap({ + frame, + yBucketScale: { type: ScaleDistribution.Linear }, + }); + + // Without yMax, should be 3 fields: xMax, y/yMin/yMax, yMax, count + const valueField = heatmap.fields.find((f) => f.name === 'count'); + expect(valueField).toBeDefined(); + }); + + it('uses "Value" as field name for ordinal scale', () => { + const frame = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1000] }, + { name: 'low', type: FieldType.number, values: [10] }, + { name: 'high', type: FieldType.number, values: [20] }, + ], + }); + + const heatmap = rowsToCellsHeatmap({ frame }); + + const valueField = heatmap.fields.find((f) => f.name === 'Value'); + expect(valueField).toBeDefined(); + }); + + it('respects custom value field name for linear scale', () => { + const frame = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1000] }, + { name: '1', type: FieldType.number, values: [10] }, + { name: '10', type: FieldType.number, values: [20] }, + ], + }); + + const heatmap = rowsToCellsHeatmap({ + frame, + yBucketScale: { type: ScaleDistribution.Linear }, + value: 'Temperature', + }); + + const valueField = heatmap.fields.find((f) => f.name === 'Temperature'); + expect(valueField).toBeDefined(); + }); + + it('calculates yMax upper bound using bucket factor', () => { + const frame = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1000] }, + { name: '1', type: FieldType.number, values: [10] }, + { name: '10', type: FieldType.number, values: [20] }, + { name: '100', type: FieldType.number, values: [30] }, + ], + }); + + const heatmap = rowsToCellsHeatmap({ + frame, + yBucketScale: { type: ScaleDistribution.Linear }, + }); + + // buckets: [1, 10, 100] + // yMax: [10, 100, 1000] - last one calculated as 100 * 10 + const yMaxField = heatmap.fields.find((f) => f.name === 'yMax'); + expect(yMaxField?.values).toEqual([10, 100, 1000]); + }); + }); }); diff --git a/public/app/features/transformers/calculateHeatmap/heatmap.ts b/public/app/features/transformers/calculateHeatmap/heatmap.ts index 49a54bb43f1..43640708eae 100644 --- a/public/app/features/transformers/calculateHeatmap/heatmap.ts +++ b/public/app/features/transformers/calculateHeatmap/heatmap.ts @@ -19,6 +19,7 @@ import { isLikelyAscendingVector } from '@grafana/data/internal'; import { t } from '@grafana/i18n'; import { ScaleDistribution, + ScaleDistributionConfig, HeatmapCellLayout, HeatmapCalculationMode, HeatmapCalculationOptions, @@ -72,13 +73,36 @@ function parseNumeric(v?: string | null) { return v === '+Inf' ? Infinity : v === '-Inf' ? -Infinity : +(v ?? 0); } +/** + * Calculate the expansion factor from adjacent bucket values. + * This is used to estimate the size/bound of the next bucket based on the spacing of existing buckets. + * + * @param bucketValues - Array of bucket boundary values + * @param defaultFactor - Factor to use if ratio cannot be determined (default: 1.5 for 50% expansion) + * @returns The calculated or default expansion factor + */ +export function calculateBucketFactor(bucketValues: number[], defaultFactor = 1.5): number { + if (bucketValues.length >= 2) { + const last = bucketValues.at(-1)!; + const prev = bucketValues.at(-2)!; + const ratio = last / prev; + + // Only use ratio if it represents expansion (>1) and is valid + if (ratio > 1 && Number.isFinite(ratio)) { + return ratio; + } + } + + return defaultFactor; +} + export function sortAscStrInf(aName?: string | null, bName?: string | null) { return parseNumeric(aName) - parseNumeric(bName); } export interface HeatmapRowsCustomMeta { /** This provides the lookup values */ - yOrdinalDisplay: string[]; + yOrdinalDisplay?: string[]; yOrdinalLabel?: string[]; yMatchWithLabel?: string; yMinDisplay?: string; @@ -115,6 +139,7 @@ export interface RowsHeatmapOptions { unit?: string; decimals?: number; layout?: HeatmapCellLayout; + yBucketScale?: ScaleDistributionConfig; } /** Given existing buckets, create a values style frame */ @@ -129,10 +154,19 @@ export function rowsToCellsHeatmap(opts: RowsHeatmapOptions): DataFrame { throw new Error(t('heatmap.error.no-y-fields', 'No numeric fields found for heatmap')); } + // Determine if we should use numeric scaling based on yBucketScale option + // Default to 'auto' behavior (ordinal) if not specified + const scaleType = opts.yBucketScale?.type; + const useNumericScale = + scaleType === ScaleDistribution.Linear || + scaleType === ScaleDistribution.Log || + scaleType === ScaleDistribution.Symlog; + // similar to initBins() below const len = xValues.length * yFields.length; const xs = new Array(len); const ys = new Array(len); + const ys2 = useNumericScale ? new Array(len) : undefined; const counts2 = new Array(len); const counts = yFields.map((field) => field.values.slice()); @@ -144,21 +178,8 @@ export function rowsToCellsHeatmap(opts: RowsHeatmapOptions): DataFrame { } }); - const bucketBounds = Array.from({ length: yFields.length }, (v, i) => i); - - // fill flat/repeating array - for (let i = 0, yi = 0, xi = 0; i < len; yi = ++i % bucketBounds.length) { - ys[i] = bucketBounds[yi]; - - if (yi === 0 && i >= bucketBounds.length) { - xi++; - } - - xs[i] = xValues[xi]; - } - // this name determines whether cells are drawn above, below, or centered on the values - let ordinalFieldName = yFields[0].labels?.le != null ? 'yMax' : 'y'; + let ordinalFieldName = yFields[0].labels?.le != null ? 'yMax' : yFields[0].labels?.ge != null ? 'yMin' : 'y'; switch (opts.layout) { case HeatmapCellLayout.le: ordinalFieldName = 'yMax'; @@ -175,6 +196,45 @@ export function rowsToCellsHeatmap(opts: RowsHeatmapOptions): DataFrame { yOrdinalDisplay: yFields.map((f) => getFieldDisplayName(f, opts.frame)), yMatchWithLabel: Object.keys(yFields[0].labels ?? {})[0], }; + + let bucketBounds: number[]; + let bucketBoundsMax: number[] | undefined; + + if (useNumericScale) { + // Numeric mode: use numeric bucket values + bucketBounds = yFields.map((field) => { + const labelKey = custom.yMatchWithLabel; + const labelValue = labelKey ? field.labels?.[labelKey] : undefined; + const valueStr = labelValue ?? field.name; + return Number(valueStr); + }); + + // Generate upper bounds: shift values + calculate last bucket + bucketBoundsMax = bucketBounds.slice(); + bucketBoundsMax.shift(); + const factor = calculateBucketFactor(bucketBounds); + bucketBoundsMax.push(bucketBounds[bucketBounds.length - 1] * factor); + + custom.yMatchWithLabel = undefined; + } else { + // Auto mode: use ordinal indices like the original main branch behavior + bucketBounds = Array.from({ length: yFields.length }, (v, i) => i); + } + + // fill flat/repeating array + for (let i = 0, yi = 0, xi = 0; i < len; yi = ++i % bucketBounds.length) { + ys[i] = bucketBounds[yi]; + if (useNumericScale && ys2 && bucketBoundsMax) { + ys2[i] = bucketBoundsMax[yi]; + } + + if (yi === 0 && i >= bucketBounds.length) { + xi++; + } + + xs[i] = xValues[xi]; + } + if (custom.yMatchWithLabel) { custom.yOrdinalLabel = yFields.map((f) => f.labels?.[custom.yMatchWithLabel!] ?? ''); if (custom.yMatchWithLabel === 'le') { @@ -189,7 +249,7 @@ export function rowsToCellsHeatmap(opts: RowsHeatmapOptions): DataFrame { if (custom.yMinDisplay) { custom.yMinDisplay = formattedValueToString(fmt(0, opts.decimals)); } - custom.yOrdinalDisplay = custom.yOrdinalDisplay.map((name) => { + custom.yOrdinalDisplay = custom.yOrdinalDisplay?.map((name) => { let num = +name; if (!Number.isNaN(num)) { @@ -200,6 +260,11 @@ export function rowsToCellsHeatmap(opts: RowsHeatmapOptions): DataFrame { }); } + // Clear yOrdinalDisplay when using numeric scales (linear, log, symlog) + if (useNumericScale) { + custom.yOrdinalDisplay = undefined; + } + const valueCfg = { ...yFields[0].config, }; @@ -208,6 +273,43 @@ export function rowsToCellsHeatmap(opts: RowsHeatmapOptions): DataFrame { delete valueCfg.displayNameFromDS; } + // Build fields array - only include yMax in linear scale mode + const fields: Field[] = [ + { + name: xField.type === FieldType.time ? 'xMax' : 'x', + type: xField.type, + values: xs, + config: xField.config, + }, + { + name: useNumericScale ? 'yMin' : ordinalFieldName, + type: FieldType.number, + values: ys, + config: { + unit: useNumericScale ? yFields[0]?.config?.unit : 'short', // preserve original unit for numeric, use 'short' for ordinal + }, + }, + ]; + + // yMax provides explicit upper bounds for proper rendering, critical for ge layout + if (useNumericScale && ys2) { + fields.push({ + name: 'yMax', + type: FieldType.number, + values: ys2, + config: {}, + }); + } + + // Add value/count field + fields.push({ + name: opts.value?.length ? opts.value : useNumericScale ? 'count' : 'Value', + type: FieldType.number, + values: counts2, + config: valueCfg, + display: yFields[0].display, + }); + return { length: xs.length, refId: opts.frame.refId, @@ -215,29 +317,7 @@ export function rowsToCellsHeatmap(opts: RowsHeatmapOptions): DataFrame { type: DataFrameType.HeatmapCells, custom, }, - fields: [ - { - name: xField.type === FieldType.time ? 'xMax' : 'x', - type: xField.type, - values: xs, - config: xField.config, - }, - { - name: ordinalFieldName, - type: FieldType.number, - values: ys, - config: { - unit: 'short', // ordinal lookup - }, - }, - { - name: opts.value?.length ? opts.value : 'Value', - type: FieldType.number, - values: counts2, - config: valueCfg, - display: yFields[0].display, - }, - ], + fields, }; } diff --git a/public/app/plugins/panel/heatmap/HeatmapPanel.tsx b/public/app/plugins/panel/heatmap/HeatmapPanel.tsx index 4d2762a9be3..64efef6c5aa 100644 --- a/public/app/plugins/panel/heatmap/HeatmapPanel.tsx +++ b/public/app/plugins/panel/heatmap/HeatmapPanel.tsx @@ -5,7 +5,6 @@ import { DashboardCursorSync, PanelProps, TimeRange } from '@grafana/data'; import { PanelDataErrorView } from '@grafana/runtime'; import { ScaleDistributionConfig } from '@grafana/schema'; import { - ScaleDistribution, TooltipPlugin2, TooltipDisplayMode, UPlotChart, @@ -29,7 +28,7 @@ import { HeatmapTooltip } from './HeatmapTooltip'; import { HeatmapData, prepareHeatmapData } from './fields'; import { quantizeScheme } from './palettes'; import { Options } from './types'; -import { prepConfig } from './utils'; +import { calculateYSizeDivisor, prepConfig } from './utils'; interface HeatmapPanelProps extends PanelProps {} @@ -141,6 +140,16 @@ const HeatmapPanelViz = ({ const builder = useMemo(() => { const scaleConfig: ScaleDistributionConfig = dataRef.current?.heatmap?.fields[1].config?.custom?.scaleDistribution; + const activeScaleConfig = options.rowsFrame?.yBucketScale ?? scaleConfig; + + // For log/symlog scales: use 1 for pre-bucketed data with explicit scale, otherwise use split value + const hasExplicitScale = options.rowsFrame?.yBucketScale !== undefined; + const ySizeDivisor = calculateYSizeDivisor( + activeScaleConfig?.type, + hasExplicitScale, + options.calculation?.yBuckets?.value + ); + return prepConfig({ dataRef, theme, @@ -151,9 +160,10 @@ const HeatmapPanelViz = ({ hideGE: options.filterValues?.ge, exemplarColor: options.exemplars?.color ?? 'rgba(255,0,255,0.7)', yAxisConfig: options.yAxis, - ySizeDivisor: scaleConfig?.type === ScaleDistribution.Log ? +(options.calculation?.yBuckets?.value || 1) : 1, + ySizeDivisor, selectionMode: options.selectionMode, xAxisConfig: getXAxisConfig(annotationsLength), + rowsFrame: options.rowsFrame, }); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/public/app/plugins/panel/heatmap/HeatmapTooltip.tsx b/public/app/plugins/panel/heatmap/HeatmapTooltip.tsx index 480d36a5a6a..269243f1ad8 100644 --- a/public/app/plugins/panel/heatmap/HeatmapTooltip.tsx +++ b/public/app/plugins/panel/heatmap/HeatmapTooltip.tsx @@ -4,7 +4,6 @@ import uPlot from 'uplot'; import { ActionModel, - DataFrameType, Field, FieldType, formattedValueToString, @@ -26,7 +25,7 @@ import { } from '@grafana/ui/internal'; import { ColorScale } from 'app/core/components/ColorScale/ColorScale'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; -import { isHeatmapCellsDense, readHeatmapRowsCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap'; +import { readHeatmapRowsCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap'; import { getDisplayValuesAndLinks } from 'app/features/visualization/data-hover/DataHoverView'; import { ExemplarTooltip } from 'app/features/visualization/data-hover/ExemplarTooltip'; @@ -35,7 +34,13 @@ import { isTooltipScrollable } from '../timeseries/utils'; import { HeatmapData } from './fields'; import { renderHistogram } from './renderHistogram'; -import { formatMilliseconds, getFieldFromData, getHoverCellColor, getSparseCellMinMax } from './tooltip/utils'; +import { + formatMilliseconds, + getFieldFromData, + getHoverCellColor, + getSparseCellMinMax, + isHeatmapSparse, +} from './tooltip/utils'; interface HeatmapTooltipProps { mode: TooltipDisplayMode; @@ -99,9 +104,7 @@ const HeatmapHoverCell = ({ const index = dataIdxs[1]!; const data = dataRef.current; - const [isSparse] = useState( - () => data.heatmap?.meta?.type === DataFrameType.HeatmapCells && !isHeatmapCellsDense(data.heatmap) - ); + const [isSparse] = useState(() => isHeatmapSparse(data.heatmap)); const xField = getFieldFromData(data.heatmap!, 'x', isSparse)!; const yField = getFieldFromData(data.heatmap!, 'y', isSparse)!; diff --git a/public/app/plugins/panel/heatmap/YBucketScaleEditor.test.tsx b/public/app/plugins/panel/heatmap/YBucketScaleEditor.test.tsx new file mode 100644 index 00000000000..6c0ff4b804c --- /dev/null +++ b/public/app/plugins/panel/heatmap/YBucketScaleEditor.test.tsx @@ -0,0 +1,277 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import selectEvent from 'react-select-event'; + +import { StandardEditorContext, StandardEditorsRegistryItem } from '@grafana/data'; +import { ScaleDistribution, ScaleDistributionConfig } from '@grafana/schema'; + +import { YBucketScaleEditor } from './YBucketScaleEditor'; + +const mockContext: StandardEditorContext = { + data: [], +}; + +const mockItem: StandardEditorsRegistryItem = { + id: 'yBucketScale', + name: 'Y Bucket Scale', + editor: YBucketScaleEditor, +}; + +describe('YBucketScaleEditor', () => { + describe('Scale selection', () => { + it('should render with Auto selected when value is undefined', () => { + const onChange = jest.fn(); + render(); + + const autoButton = screen.getByRole('radio', { name: /auto/i }); + expect(autoButton).toBeChecked(); + }); + + it('should render with Linear selected when value is Linear', () => { + const onChange = jest.fn(); + render( + + ); + + const linearButton = screen.getByRole('radio', { name: /linear/i }); + expect(linearButton).toBeChecked(); + }); + + it('should call onChange with undefined when Auto is selected', async () => { + const onChange = jest.fn(); + render( + + ); + + const autoButton = screen.getByRole('radio', { name: /auto/i }); + await userEvent.click(autoButton); + + expect(onChange).toHaveBeenCalledWith(undefined); + }); + + it('should call onChange with Linear config when Linear is selected', async () => { + const onChange = jest.fn(); + render(); + + const linearButton = screen.getByRole('radio', { name: /linear/i }); + await userEvent.click(linearButton); + + expect(onChange).toHaveBeenCalledWith({ type: ScaleDistribution.Linear }); + }); + + it('should call onChange with Log config when Log is selected', async () => { + const onChange = jest.fn(); + render(); + + const logButton = screen.getByRole('radio', { name: /^log$/i }); + await userEvent.click(logButton); + + expect(onChange).toHaveBeenCalledWith({ type: ScaleDistribution.Log, log: 2 }); + }); + + it('should call onChange with Symlog config when Symlog is selected', async () => { + const onChange = jest.fn(); + render(); + + const symlogButton = screen.getByRole('radio', { name: /symlog/i }); + await userEvent.click(symlogButton); + + expect(onChange).toHaveBeenCalledWith({ type: ScaleDistribution.Symlog, log: 2, linearThreshold: 1 }); + }); + }); + + describe('Log base selection', () => { + it('should show log base selector for Log scale', () => { + const onChange = jest.fn(); + render( + + ); + + expect(screen.getByText('Log base')).toBeInTheDocument(); + }); + + it('should show log base selector for Symlog scale', () => { + const onChange = jest.fn(); + render( + + ); + + expect(screen.getByText('Log base')).toBeInTheDocument(); + }); + + it('should not show log base selector for Linear scale', () => { + const onChange = jest.fn(); + render( + + ); + + expect(screen.queryByText('Log base')).not.toBeInTheDocument(); + }); + + it('should not show log base selector for Auto', () => { + const onChange = jest.fn(); + render(); + + expect(screen.queryByText('Log base')).not.toBeInTheDocument(); + }); + + it('should preserve existing log base when switching to Log', async () => { + const onChange = jest.fn(); + render( + + ); + + const logButton = screen.getByRole('radio', { name: /^log$/i }); + await userEvent.click(logButton); + + expect(onChange).toHaveBeenCalledWith({ type: ScaleDistribution.Log, log: 10 }); + }); + + it('should update log base when changed for Log scale', async () => { + const onChange = jest.fn(); + render( + + ); + + // Find the log base field container and query the combobox within it + const logBaseLabel = screen.getByText('Log base'); + const fieldContainer = logBaseLabel.closest('div[style]') as HTMLElement; // The div with style="margin-top: 8px;" + const selectEl = within(fieldContainer).getByRole('combobox'); + + await selectEvent.select(selectEl, '10', { container: document.body }); + + expect(onChange).toHaveBeenCalledWith({ type: ScaleDistribution.Log, log: 10 }); + }); + + it('should update log base when changed for Symlog scale', async () => { + const onChange = jest.fn(); + render( + + ); + + // Find the log base field container and query the combobox within it + const logBaseLabel = screen.getByText('Log base'); + const fieldContainer = logBaseLabel.closest('div[style]') as HTMLElement; // The div with style="margin-top: 8px;" + const selectEl = within(fieldContainer).getByRole('combobox'); + + await selectEvent.select(selectEl, '10', { container: document.body }); + + expect(onChange).toHaveBeenCalledWith({ type: ScaleDistribution.Symlog, log: 10, linearThreshold: 1 }); + }); + }); + + describe('Linear threshold', () => { + it('should show linear threshold input for Symlog scale', () => { + const onChange = jest.fn(); + render( + + ); + + expect(screen.getByText('Linear threshold')).toBeInTheDocument(); + }); + + it('should not show linear threshold input for Log scale', () => { + const onChange = jest.fn(); + render( + + ); + + expect(screen.queryByText('Linear threshold')).not.toBeInTheDocument(); + }); + + it('should not update linear threshold for a 0 value', async () => { + const onChange = jest.fn(); + const origValue = { type: ScaleDistribution.Symlog, log: 10, linearThreshold: 1 }; + + render(); + + const input = screen.getByPlaceholderText('1'); + + await userEvent.clear(input); + await userEvent.type(input, '0'); + expect(onChange).not.toHaveBeenCalled(); + + await userEvent.type(input, '.'); + expect(onChange).not.toHaveBeenCalled(); + + await userEvent.type(input, '5'); + expect(onChange).toHaveBeenCalledWith({ ...origValue, linearThreshold: 0.5 }); + }); + + it('should update linear threshold for valid non-zero values', async () => { + const onChange = jest.fn(); + const origValue = { type: ScaleDistribution.Symlog, log: 2, linearThreshold: 1 }; + + render(); + + const input = screen.getByPlaceholderText('1'); + + await userEvent.clear(input); + await userEvent.type(input, '5'); + expect(onChange).toHaveBeenCalledWith({ ...origValue, linearThreshold: 5 }); + }); + + it('should not dispatch onChange for invalid input', async () => { + const onChange = jest.fn(); + const origValue = { type: ScaleDistribution.Symlog, log: 2, linearThreshold: 1 }; + + render(); + + const input = screen.getByPlaceholderText('1'); + + await userEvent.clear(input); + await userEvent.type(input, 'abc'); + expect(onChange).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/public/app/plugins/panel/heatmap/YBucketScaleEditor.tsx b/public/app/plugins/panel/heatmap/YBucketScaleEditor.tsx new file mode 100644 index 00000000000..fc96101f9bd --- /dev/null +++ b/public/app/plugins/panel/heatmap/YBucketScaleEditor.tsx @@ -0,0 +1,135 @@ +import { useState } from 'react'; + +import { SelectableValue, StandardEditorProps } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { ScaleDistribution, ScaleDistributionConfig } from '@grafana/schema'; +import { RadioButtonGroup, Field, Select, Input } from '@grafana/ui'; + +type ScaleOptionValue = 'auto' | ScaleDistribution; + +/** + * Simplified scale editor that shows all options in a single line. + * Includes "Auto" option which returns undefined to use default behavior. + */ +export const YBucketScaleEditor = (props: StandardEditorProps) => { + const { value, onChange } = props; + + const type = value?.type; + const log = value?.log ?? 2; + const isAuto = value === undefined; + + const [localLinearThreshold, setLocalLinearThreshold] = useState( + value?.linearThreshold != null ? String(value.linearThreshold) : '' + ); + + const currentOption: ScaleOptionValue = isAuto ? 'auto' : type!; + const showLogBase = type === ScaleDistribution.Log || type === ScaleDistribution.Symlog; + const showLinearThreshold = type === ScaleDistribution.Symlog; + + const SCALE_OPTIONS: Array> = [ + { + label: t('heatmap.y-bucket-scale-editor.scale-options.label-auto', 'Auto'), + value: 'auto', + }, + { + label: t('heatmap.y-bucket-scale-editor.scale-options.label-linear', 'Linear'), + value: ScaleDistribution.Linear, + }, + { + label: t('heatmap.y-bucket-scale-editor.scale-options.label-log', 'Log'), + value: ScaleDistribution.Log, + }, + { + label: t('heatmap.y-bucket-scale-editor.scale-options.label-symlog', 'Symlog'), + value: ScaleDistribution.Symlog, + }, + ]; + + const LOG_BASE_OPTIONS: Array> = [ + { + label: '2', + value: 2, + }, + { + label: '10', + value: 10, + }, + ]; + + const handleScaleChange = (v: ScaleOptionValue) => { + if (v === 'auto') { + onChange(undefined); + return; + } + + if (v === ScaleDistribution.Linear) { + onChange({ type: ScaleDistribution.Linear }); + return; + } + + if (v === ScaleDistribution.Log) { + onChange({ type: ScaleDistribution.Log, log }); + return; + } + + if (v === ScaleDistribution.Symlog) { + onChange({ + type: ScaleDistribution.Symlog, + log, + linearThreshold: value?.linearThreshold ?? 1, + }); + return; + } + }; + + const handleLogBaseChange = (newLog: number) => { + onChange({ + ...value!, + log: newLog, + }); + }; + + const handleLinearThresholdChange = (newValue: string) => { + setLocalLinearThreshold(newValue); + const numValue = parseFloat(newValue); + if (!isNaN(numValue) && numValue !== 0) { + onChange({ + ...value!, + linearThreshold: numValue, + }); + } + }; + + return ( + <> + + {showLogBase && ( + + handleLinearThresholdChange(e.currentTarget.value)} + placeholder={t('heatmap.y-bucket-scale-editor.linear-threshold-placeholder', '1')} + /> + + )} + + ); +}; diff --git a/public/app/plugins/panel/heatmap/module.tsx b/public/app/plugins/panel/heatmap/module.tsx index a7d44726886..99bb66e2c3c 100644 --- a/public/app/plugins/panel/heatmap/module.tsx +++ b/public/app/plugins/panel/heatmap/module.tsx @@ -1,4 +1,11 @@ -import { DataFrame, FieldConfigProperty, FieldType, identityOverrideProcessor, PanelPlugin } from '@grafana/data'; +import { + DataFrame, + DataFrameType, + FieldConfigProperty, + FieldType, + identityOverrideProcessor, + PanelPlugin, +} from '@grafana/data'; import { t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; import { @@ -15,6 +22,7 @@ import { addHeatmapCalculationOptions } from 'app/features/transformers/calculat import { readHeatmapRowsCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap'; import { HeatmapPanel } from './HeatmapPanel'; +import { YBucketScaleEditor } from './YBucketScaleEditor'; import { prepareHeatmapData } from './fields'; import { heatmapChangedHandler, heatmapMigrationHandler } from './migrations'; import { colorSchemes, quantizeScheme } from './palettes'; @@ -59,6 +67,7 @@ export const plugin = new PanelPlugin(HeatmapPanel) const opts = context.options ?? defaultOptions; let isOrdinalY = false; + const isHeatmapCells = context.data.some((frame) => frame.meta?.type === DataFrameType.HeatmapCells); if (context.data.length > 0) { try { @@ -94,6 +103,17 @@ export const plugin = new PanelPlugin(HeatmapPanel) addHeatmapCalculationOptions('calculation.', builder, opts.calculation, category); } + if (!opts.calculate && !isHeatmapCells && config.featureToggles.heatmapRowsAxisOptions) { + builder.addCustomEditor({ + id: 'rowsFrame-yBucketScale', + path: 'rowsFrame.yBucketScale', + name: t('heatmap.name-y-bucket-scale', 'Y bucket scale'), + category, + editor: YBucketScaleEditor, + defaultValue: undefined, + }); + } + category = [t('heatmap.category-y-axis', 'Y Axis')]; builder @@ -170,7 +190,9 @@ export const plugin = new PanelPlugin(HeatmapPanel) category, }); - if (!opts.calculate) { + // Hide tick alignment for explicit scales - bucket boundaries are fixed by numeric labels + const hasExplicitScale = context.options?.rowsFrame?.yBucketScale !== undefined; + if (!opts.calculate && !hasExplicitScale) { builder.addRadio({ path: 'rowsFrame.layout', name: t('heatmap.name-tick-alignment', 'Tick alignment'), diff --git a/public/app/plugins/panel/heatmap/panelcfg.cue b/public/app/plugins/panel/heatmap/panelcfg.cue index e947aaf9bb6..e07717429d7 100644 --- a/public/app/plugins/panel/heatmap/panelcfg.cue +++ b/public/app/plugins/panel/heatmap/panelcfg.cue @@ -105,6 +105,8 @@ composableKinds: PanelCfg: lineage: { value?: string // Controls tick alignment when not calculating from data layout?: ui.HeatmapCellLayout + // Controls the scale distribution of the y-axis buckets + yBucketScale?: ui.ScaleDistributionConfig } @cuetsy(kind="interface") Options: { annotations?: ui.VizAnnotations diff --git a/public/app/plugins/panel/heatmap/panelcfg.gen.ts b/public/app/plugins/panel/heatmap/panelcfg.gen.ts index ef3ab0c9459..d5b822eb1ab 100644 --- a/public/app/plugins/panel/heatmap/panelcfg.gen.ts +++ b/public/app/plugins/panel/heatmap/panelcfg.gen.ts @@ -183,6 +183,10 @@ export interface RowsHeatmapOptions { * Sets the name of the cell when not calculating from data */ value?: string; + /** + * Controls the scale distribution of the y-axis buckets + */ + yBucketScale?: ui.ScaleDistributionConfig; } export interface Options { diff --git a/public/app/plugins/panel/heatmap/tooltip/utils.test.ts b/public/app/plugins/panel/heatmap/tooltip/utils.test.ts new file mode 100644 index 00000000000..a5da298d75c --- /dev/null +++ b/public/app/plugins/panel/heatmap/tooltip/utils.test.ts @@ -0,0 +1,47 @@ +import { DataFrameType, toDataFrame } from '@grafana/data'; + +import { isHeatmapSparse } from './utils'; + +describe('isHeatmapSparse', () => { + it('should return false when heatmap is undefined', () => { + expect(isHeatmapSparse(undefined)).toBe(false); + }); + + it('should return false for dense HeatmapCells (single Y field)', () => { + const heatmap = toDataFrame({ + fields: [{ name: 'y', values: [] }], + meta: { type: DataFrameType.HeatmapCells }, + }); + + expect(isHeatmapSparse(heatmap)).toBe(false); + }); + + it('should return true for sparse HeatmapCells (yMin and yMax fields)', () => { + const heatmap = toDataFrame({ + fields: [ + { name: 'yMin', values: [] }, + { name: 'yMax', values: [] }, + ], + meta: { type: DataFrameType.HeatmapCells }, + }); + + expect(isHeatmapSparse(heatmap)).toBe(true); + }); + + it('should return false for non-HeatmapCells data frames', () => { + const heatmap = toDataFrame({ + fields: [{ name: 'Value', values: [] }], + meta: { type: DataFrameType.HeatmapRows }, + }); + + expect(isHeatmapSparse(heatmap)).toBe(false); + }); + + it('should return false when meta is undefined', () => { + const heatmap = toDataFrame({ + fields: [{ name: 'value', values: [] }], + }); + + expect(isHeatmapSparse(heatmap)).toBe(false); + }); +}); diff --git a/public/app/plugins/panel/heatmap/tooltip/utils.ts b/public/app/plugins/panel/heatmap/tooltip/utils.ts index 58114245d4e..067bf7d2b71 100644 --- a/public/app/plugins/panel/heatmap/tooltip/utils.ts +++ b/public/app/plugins/panel/heatmap/tooltip/utils.ts @@ -1,4 +1,5 @@ -import { DataFrame, Field } from '@grafana/data'; +import { DataFrame, DataFrameType, Field } from '@grafana/data'; +import { isHeatmapCellsDense } from 'app/features/transformers/calculateHeatmap/heatmap'; import { HeatmapData } from '../fields'; @@ -91,3 +92,14 @@ export const getSparseCellMinMax = (data: HeatmapData, index: number): BucketsMi yBucketMax: yMax.values[index], }; }; + +/** + * Determines if a heatmap DataFrame is sparse (has explicit yMin/yMax bounds). + * Sparse heatmaps have HeatmapCells type and are not dense. + */ +export function isHeatmapSparse(heatmap: DataFrame | undefined): boolean { + if (!heatmap) { + return false; + } + return heatmap.meta?.type === DataFrameType.HeatmapCells && !isHeatmapCellsDense(heatmap); +} diff --git a/public/app/plugins/panel/heatmap/utils.test.ts b/public/app/plugins/panel/heatmap/utils.test.ts index 26fecaad53b..abb10812469 100644 --- a/public/app/plugins/panel/heatmap/utils.test.ts +++ b/public/app/plugins/panel/heatmap/utils.test.ts @@ -1,5 +1,374 @@ -describe('a test', () => { - it('has to have at least one test', () => { - expect(true).toBeTruthy(); +import { ScaleDistribution } from '@grafana/schema'; + +import { applyExplicitMinMax, boundedMinMax, calculateYSizeDivisor, toLogBase, valuesToFills } from './utils'; + +describe('toLogBase', () => { + it('returns 10 when value is 10', () => { + expect(toLogBase(10)).toBe(10); + }); + + it('returns 2 when value is 2', () => { + expect(toLogBase(2)).toBe(2); + }); + + it('returns 2 (default) when value is undefined', () => { + expect(toLogBase(undefined)).toBe(2); + }); + + it('returns 2 (default) for invalid values', () => { + expect(toLogBase(5)).toBe(2); + expect(toLogBase(0)).toBe(2); + expect(toLogBase(-1)).toBe(2); + expect(toLogBase(100)).toBe(2); + }); +}); + +describe('applyExplicitMinMax', () => { + it('returns original values when no explicit values provided', () => { + const [min, max] = applyExplicitMinMax(0, 100, undefined, undefined); + expect(min).toBe(0); + expect(max).toBe(100); + }); + + it('applies explicit min only', () => { + const [min, max] = applyExplicitMinMax(0, 100, 10, undefined); + expect(min).toBe(10); + expect(max).toBe(100); + }); + + it('applies explicit max only', () => { + const [min, max] = applyExplicitMinMax(0, 100, undefined, 90); + expect(min).toBe(0); + expect(max).toBe(90); + }); + + it('applies both explicit min and max', () => { + const [min, max] = applyExplicitMinMax(0, 100, 20, 80); + expect(min).toBe(20); + expect(max).toBe(80); + }); + + it('handles negative values', () => { + const [min, max] = applyExplicitMinMax(-50, 50, -10, 10); + expect(min).toBe(-10); + expect(max).toBe(10); + }); + + it('handles explicit min = 0', () => { + const [min, max] = applyExplicitMinMax(10, 100, 0, undefined); + expect(min).toBe(0); + expect(max).toBe(100); + }); + + it('handles explicit max = 0', () => { + const [min, max] = applyExplicitMinMax(-100, -10, undefined, 0); + expect(min).toBe(-100); + expect(max).toBe(0); + }); + + it('handles null scaleMin', () => { + const [min, max] = applyExplicitMinMax(null, 100, 10, undefined); + expect(min).toBe(10); + expect(max).toBe(100); + }); + + it('handles null scaleMax', () => { + const [min, max] = applyExplicitMinMax(0, null, undefined, 90); + expect(min).toBe(0); + expect(max).toBe(90); + }); + + it('preserves null when no explicit value provided', () => { + const [min, max] = applyExplicitMinMax(null, null, undefined, undefined); + expect(min).toBe(null); + expect(max).toBe(null); + }); +}); + +describe('calculateYSizeDivisor', () => { + it('returns 1 for linear scale', () => { + expect(calculateYSizeDivisor(ScaleDistribution.Linear, false, 2)).toBe(1); + }); + + it('returns 1 for log scale with explicit scale', () => { + expect(calculateYSizeDivisor(ScaleDistribution.Log, true, 2)).toBe(1); + }); + + it('returns 1 for symlog scale with explicit scale', () => { + expect(calculateYSizeDivisor(ScaleDistribution.Symlog, true, 2)).toBe(1); + }); + + it('returns split value for log scale without explicit scale', () => { + expect(calculateYSizeDivisor(ScaleDistribution.Log, false, 2)).toBe(2); + expect(calculateYSizeDivisor(ScaleDistribution.Log, false, 4)).toBe(4); + }); + + it('returns split value for symlog scale without explicit scale', () => { + expect(calculateYSizeDivisor(ScaleDistribution.Symlog, false, 2)).toBe(2); + expect(calculateYSizeDivisor(ScaleDistribution.Symlog, false, 3)).toBe(3); + }); + + it('handles string split values', () => { + expect(calculateYSizeDivisor(ScaleDistribution.Log, false, '2')).toBe(2); + expect(calculateYSizeDivisor(ScaleDistribution.Log, false, '4')).toBe(4); + }); + + it('returns 1 when split value is undefined', () => { + expect(calculateYSizeDivisor(ScaleDistribution.Log, false, undefined)).toBe(1); + }); + + it('returns 1 when scale type is undefined', () => { + expect(calculateYSizeDivisor(undefined, false, 2)).toBe(1); + }); + + it('returns 1 for ordinal scale', () => { + expect(calculateYSizeDivisor(ScaleDistribution.Ordinal, false, 2)).toBe(1); + }); +}); + +describe('boundedMinMax', () => { + describe('when min and max are not provided', () => { + it('calculates min and max from values', () => { + const values = [10, 20, 5, 30, 15]; + const [min, max] = boundedMinMax(values); + expect(min).toBe(5); + expect(max).toBe(30); + }); + + it('handles single value', () => { + const values = [42]; + const [min, max] = boundedMinMax(values); + expect(min).toBe(42); + expect(max).toBe(42); + }); + + it('handles negative values', () => { + const values = [-10, -20, -5, -30]; + const [min, max] = boundedMinMax(values); + expect(min).toBe(-30); + expect(max).toBe(-5); + }); + + it('handles mixed positive and negative values', () => { + const values = [-10, 20, -5, 30]; + const [min, max] = boundedMinMax(values); + expect(min).toBe(-10); + expect(max).toBe(30); + }); + + it('returns Infinity/-Infinity for empty array', () => { + const values: number[] = []; + const [min, max] = boundedMinMax(values); + expect(min).toBe(Infinity); + expect(max).toBe(-Infinity); + }); + }); + + describe('when min is provided', () => { + it('uses provided min value', () => { + const values = [10, 20, 5, 30]; + const [min, max] = boundedMinMax(values, 0); + expect(min).toBe(0); + expect(max).toBe(30); + }); + + it('uses provided min even if higher than data min', () => { + const values = [10, 20, 5, 30]; + const [min, max] = boundedMinMax(values, 15); + expect(min).toBe(15); + expect(max).toBe(30); + }); + }); + + describe('when max is provided', () => { + it('uses provided max value', () => { + const values = [10, 20, 5, 30]; + const [min, max] = boundedMinMax(values, undefined, 50); + expect(min).toBe(5); + expect(max).toBe(50); + }); + + it('uses provided max even if lower than data max', () => { + const values = [10, 20, 5, 30]; + const [min, max] = boundedMinMax(values, undefined, 25); + expect(min).toBe(5); + expect(max).toBe(25); + }); + }); + + describe('when both min and max are provided', () => { + it('uses both provided values', () => { + const values = [10, 20, 5, 30]; + const [min, max] = boundedMinMax(values, 0, 50); + expect(min).toBe(0); + expect(max).toBe(50); + }); + }); + + describe('with hideLE filter', () => { + it('excludes values less than or equal to hideLE', () => { + const values = [5, 10, 15, 20, 25]; + const [min, max] = boundedMinMax(values, undefined, undefined, 10); + expect(min).toBe(15); + expect(max).toBe(25); + }); + + it('excludes all values when hideLE is higher than all values', () => { + const values = [5, 10, 15]; + const [min, max] = boundedMinMax(values, undefined, undefined, 20); + expect(min).toBe(Infinity); + expect(max).toBe(-Infinity); + }); + }); + + describe('with hideGE filter', () => { + it('excludes values greater than or equal to hideGE', () => { + const values = [5, 10, 15, 20, 25]; + const [min, max] = boundedMinMax(values, undefined, undefined, -Infinity, 20); + expect(min).toBe(5); + expect(max).toBe(15); + }); + + it('excludes all values when hideGE is lower than all values', () => { + const values = [15, 20, 25]; + const [min, max] = boundedMinMax(values, undefined, undefined, -Infinity, 10); + expect(min).toBe(Infinity); + expect(max).toBe(-Infinity); + }); + }); + + describe('with both hideLE and hideGE filters', () => { + it('excludes values outside the range', () => { + const values = [5, 10, 15, 20, 25, 30]; + const [min, max] = boundedMinMax(values, undefined, undefined, 10, 25); + expect(min).toBe(15); + expect(max).toBe(20); + }); + + it('works with provided min/max bounds', () => { + const values = [5, 10, 15, 20, 25, 30]; + const [min, max] = boundedMinMax(values, 0, 50, 10, 25); + expect(min).toBe(0); + expect(max).toBe(50); + }); + }); +}); + +describe('valuesToFills', () => { + // Fake color palette for testing index mapping + const palette5 = ['c0', 'c1', 'c2', 'c3', 'c4']; + + describe('basic mapping', () => { + it('maps values to palette indices', () => { + const values = [0, 25, 50, 75, 100]; + const fills = valuesToFills(values, palette5, 0, 100); + + expect(fills).toEqual([0, 1, 2, 3, 4]); + }); + + it('maps min value to first palette index', () => { + const values = [10]; + const fills = valuesToFills(values, palette5, 10, 20); + + expect(fills[0]).toBe(0); + }); + + it('maps max value to last palette index', () => { + const values = [20]; + const fills = valuesToFills(values, palette5, 10, 20); + + expect(fills[0]).toBe(4); + }); + + it('maps mid-range values proportionally', () => { + const values = [15]; + const fills = valuesToFills(values, palette5, 10, 20); + + // 15 is middle of 10-20, should map to index 2 (middle color) + expect(fills[0]).toBe(2); + }); + }); + + describe('edge cases', () => { + it('clamps values below min to first index', () => { + const values = [5, 8, 10]; + const fills = valuesToFills(values, palette5, 10, 20); + + expect(fills[0]).toBe(0); // 5 < 10 + expect(fills[1]).toBe(0); // 8 < 10 + }); + + it('clamps values above max to last index', () => { + const values = [20, 25, 30]; + const fills = valuesToFills(values, palette5, 10, 20); + + expect(fills[0]).toBe(4); // 20 = max + expect(fills[1]).toBe(4); // 25 > max + expect(fills[2]).toBe(4); // 30 > max + }); + + it('handles zero range (min equals max)', () => { + const values = [10, 10, 10]; + const fills = valuesToFills(values, palette5, 10, 10); + + // When range is 0, defaults to 1, so all values map to 0 + expect(fills).toEqual([0, 0, 0]); + }); + + it('handles single color palette', () => { + const values = [0, 50, 100]; + const palette = ['c0']; + const fills = valuesToFills(values, palette, 0, 100); + + expect(fills).toEqual([0, 0, 0]); + }); + + it('handles large palette', () => { + const values = [50]; + const palette = Array.from({ length: 256 }, (_, i) => `c${i}`); + const fills = valuesToFills(values, palette, 0, 100); + + // 50 is 50% of 0-100, should map to 128 (middle of 256) + expect(fills[0]).toBe(128); + }); + }); + + describe('negative values', () => { + it('handles negative min and max', () => { + const values = [-10, -5, 0]; + const palette = ['c0', 'c1', 'c2']; + const fills = valuesToFills(values, palette, -10, 0); + + expect(fills[0]).toBe(0); // -10 is min + expect(fills[1]).toBe(1); // -5 is middle + expect(fills[2]).toBe(2); // 0 is max + }); + + it('handles range crossing zero', () => { + const values = [-10, 0, 10]; + const palette = ['c0', 'c1', 'c2']; + const fills = valuesToFills(values, palette, -10, 10); + + expect(fills[0]).toBe(0); // -10 is min + expect(fills[1]).toBe(1); // 0 is middle + expect(fills[2]).toBe(2); // 10 is max + }); + }); + + describe('preserves array length', () => { + it('returns array with same length as input', () => { + const values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + const palette = ['c0', 'c1']; + const fills = valuesToFills(values, palette, 1, 10); + + expect(fills.length).toBe(values.length); + }); + + it('handles empty array', () => { + const values: number[] = []; + const fills = valuesToFills(values, palette5, 0, 100); + + expect(fills).toEqual([]); + }); }); }); diff --git a/public/app/plugins/panel/heatmap/utils.ts b/public/app/plugins/panel/heatmap/utils.ts index a51c86f0d1c..6ae058f6261 100644 --- a/public/app/plugins/panel/heatmap/utils.ts +++ b/public/app/plugins/panel/heatmap/utils.ts @@ -14,13 +14,22 @@ import { } from '@grafana/data'; import { AxisPlacement, ScaleDirection, ScaleDistribution, ScaleOrientation, HeatmapCellLayout } from '@grafana/schema'; import { UPlotConfigBuilder, UPlotConfigPrepFn } from '@grafana/ui'; -import { isHeatmapCellsDense, readHeatmapRowsCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap'; +import { + calculateBucketFactor, + isHeatmapCellsDense, + readHeatmapRowsCustomMeta, +} from 'app/features/transformers/calculateHeatmap/heatmap'; import { pointWithin, Quadtree, Rect } from '../barchart/quadtree'; import { HeatmapData } from './fields'; import { FieldConfig, HeatmapSelectionMode, YAxisConfig } from './types'; +/** Validates and returns a safe log base (2 or 10), defaults to 2 if invalid */ +export function toLogBase(value: number | undefined): 2 | 10 { + return value === 10 ? 10 : 2; +} + interface PathbuilderOpts { each: (u: uPlot, seriesIdx: number, dataIdx: number, lft: number, top: number, wid: number, hgt: number) => void; gap?: number | null; @@ -54,6 +63,7 @@ interface PrepConfigOpts { ySizeDivisor?: number; selectionMode?: HeatmapSelectionMode; xAxisConfig?: Parameters[0]['xAxisConfig']; + rowsFrame?: { yBucketScale?: { type: ScaleDistribution; log?: number; linearThreshold?: number } }; } export function prepConfig(opts: PrepConfigOpts) { @@ -69,8 +79,11 @@ export function prepConfig(opts: PrepConfigOpts) { ySizeDivisor, selectionMode = HeatmapSelectionMode.X, xAxisConfig, + rowsFrame, } = opts; + const yBucketScale = rowsFrame?.yBucketScale; + const xScaleKey = 'x'; let isTime = true; @@ -196,7 +209,20 @@ export function prepConfig(opts: PrepConfigOpts) { const yScale = yFieldConfig?.scaleDistribution ?? { type: ScaleDistribution.Linear }; const yAxisReverse = Boolean(yAxisConfig.reverse); const isSparseHeatmap = heatmapType === DataFrameType.HeatmapCells && !isHeatmapCellsDense(dataRef.current?.heatmap!); - const shouldUseLogScale = yScale.type !== ScaleDistribution.Linear || isSparseHeatmap; + + const scaleDistribution = (() => { + if (yBucketScale) { + return yBucketScale.type; + } + if (yScale.type !== ScaleDistribution.Linear || isSparseHeatmap) { + return ScaleDistribution.Log; + } + return ScaleDistribution.Linear; + })(); + + const scaleLog = toLogBase(yBucketScale?.log ?? yScale.log); + const scaleLinearThreshold = yBucketScale?.linearThreshold; + const isOrdinalY = readHeatmapRowsCustomMeta(dataRef.current?.heatmap).yOrdinalDisplay != null; // random to prevent syncing y in other heatmaps @@ -210,8 +236,9 @@ export function prepConfig(opts: PrepConfigOpts) { orientation: ScaleOrientation.Vertical, direction: yAxisReverse ? ScaleDirection.Down : ScaleDirection.Up, // should be tweakable manually - distribution: shouldUseLogScale ? ScaleDistribution.Log : ScaleDistribution.Linear, - log: yScale.log ?? 2, + distribution: scaleDistribution, + log: scaleLog, + linearThreshold: scaleLinearThreshold, range: // sparse already accounts for le/ge by explicit yMin & yMax cell bounds, so no need to expand y range isSparseHeatmap @@ -224,16 +251,16 @@ export function prepConfig(opts: PrepConfigOpts) { let scaleMin: number | null, scaleMax: number | null; - [scaleMin, scaleMax] = shouldUseLogScale - ? uPlot.rangeLog(dataMin, dataMax, (yScale.log ?? 2) as unknown as uPlot.Scale.LogBase, true) - : [dataMin, dataMax]; + const isLogScale = + scaleDistribution === ScaleDistribution.Log || scaleDistribution === ScaleDistribution.Symlog; + [scaleMin, scaleMax] = isLogScale ? uPlot.rangeLog(dataMin, dataMax, scaleLog, true) : [dataMin, dataMax]; - if (shouldUseLogScale && !isOrdinalY) { + let { min: explicitMin, max: explicitMax } = yAxisConfig; + + if (isLogScale && !isOrdinalY) { let yExp = u.scales[yScaleKey].log!; let log = yExp === 2 ? Math.log2 : Math.log10; - let { min: explicitMin, max: explicitMax } = yAxisConfig; - // guard against <= 0 if (explicitMin != null && explicitMin > 0) { // snap to magnitude @@ -245,6 +272,9 @@ export function prepConfig(opts: PrepConfigOpts) { let maxLog = log(explicitMax); scaleMax = yExp ** incrRoundUp(maxLog, 1); } + } else if (!isOrdinalY) { + // Apply explicit min/max for linear scale + [scaleMin, scaleMax] = applyExplicitMinMax(scaleMin, scaleMax, explicitMin, explicitMax); } return [scaleMin, scaleMax]; @@ -257,7 +287,7 @@ export function prepConfig(opts: PrepConfigOpts) { let { min: explicitMin, max: explicitMax } = yAxisConfig; // logarithmic expansion - if (shouldUseLogScale) { + if (scaleDistribution === ScaleDistribution.Log || scaleDistribution === ScaleDistribution.Symlog) { let yExp = u.scales[yScaleKey].log!; let minExpanded = false; @@ -280,17 +310,31 @@ export function prepConfig(opts: PrepConfigOpts) { } } + // For pre-bucketed data with explicit scale, calculate expansion factor from actual bucket spacing + // For calculated heatmaps, use the full log base + let expansionFactor: number = yExp; + + if (yBucketScale !== undefined) { + // Try to infer the bucket factor from the actual data spacing + const yValues = u.data[1]?.[1]; + if (Array.isArray(yValues) && yValues.length >= 2 && typeof yValues[0] === 'number') { + expansionFactor = calculateBucketFactor(yValues, yExp); + } + } + if (dataRef.current?.yLayout === HeatmapCellLayout.le) { if (!minExpanded) { - scaleMin /= yExp; + scaleMin /= expansionFactor; } } else if (dataRef.current?.yLayout === HeatmapCellLayout.ge) { if (!maxExpanded) { - scaleMax *= yExp; + scaleMax *= expansionFactor; } } else { - scaleMin /= yExp / 2; - scaleMax *= yExp / 2; + // Unknown layout - expand both directions + const factor = Math.sqrt(expansionFactor); // Use sqrt for balanced expansion + scaleMin /= factor; + scaleMax *= factor; } if (!isOrdinalY) { @@ -383,7 +427,7 @@ export function prepConfig(opts: PrepConfigOpts) { return splits.map((v) => v < 0 ? (meta.yMinDisplay ?? '') // Check prometheus style labels - : (meta.yOrdinalDisplay[v] ?? '') + : (meta.yOrdinalDisplay?.[v] ?? '') ); } return splits; @@ -585,15 +629,19 @@ export function heatmapPathsDense(opts: PathbuilderOpts) { let ySize: number; if (scaleX.distr === 3) { - xSize = Math.abs(valToPosX(xs[0] * scaleX.log!, scaleX, xDim, xOff) - valToPosX(xs[0], scaleX, xDim, xOff)); + // For log scales, calculate cell size from actual adjacent bucket positions + const nextXValue = xs[yBinQty] ?? xs[0] * scaleX.log!; + xSize = Math.abs(valToPosX(nextXValue, scaleX, xDim, xOff) - valToPosX(xs[0], scaleX, xDim, xOff)); } else { xSize = Math.abs(valToPosX(xBinIncr, scaleX, xDim, xOff) - valToPosX(0, scaleX, xDim, xOff)); } if (scaleY.distr === 3) { - ySize = - Math.abs(valToPosY(ys[0] * scaleY.log!, scaleY, yDim, yOff) - valToPosY(ys[0], scaleY, yDim, yOff)) / - ySizeDivisor; + // Use actual data spacing for pre-bucketed data, or full magnitude for calculated heatmaps with splits + const nextYValue = ySizeDivisor === 1 ? (ys[1] ?? ys[0] * scaleY.log!) : ys[0] * scaleY.log!; + + const baseYSize = Math.abs(valToPosY(nextYValue, scaleY, yDim, yOff) - valToPosY(ys[0], scaleY, yDim, yOff)); + ySize = baseYSize / ySizeDivisor; } else { ySize = Math.abs(valToPosY(yBinIncr, scaleY, yDim, yOff) - valToPosY(0, scaleY, yDim, yOff)) / ySizeDivisor; } @@ -882,3 +930,30 @@ export const valuesToFills = (values: number[], palette: string[], minValue: num return indexedFills; }; + +/** + * Calculates the Y-axis size divisor for heatmap cell rendering. + * For log/symlog scales with calculated data (no explicit scale), divides cells by the split value. + * Otherwise returns 1 (no division). + */ +export function calculateYSizeDivisor( + scaleType: ScaleDistribution | undefined, + hasExplicitScale: boolean, + splitValue: number | string | undefined +): number { + const isLogScale = scaleType === ScaleDistribution.Log || scaleType === ScaleDistribution.Symlog; + return isLogScale && !hasExplicitScale ? +(splitValue || 1) : 1; +} + +/** + * Applies explicit min/max values to scale range for linear scales. + * Returns the original values if explicitMin/explicitMax are undefined. + */ +export function applyExplicitMinMax( + scaleMin: number | null, + scaleMax: number | null, + explicitMin: number | undefined, + explicitMax: number | undefined +): [number | null, number | null] { + return [explicitMin ?? scaleMin, explicitMax ?? scaleMax]; +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 6949fa04bb3..0de5ac009db 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -9361,6 +9361,7 @@ "name-unit": "Unit", "name-value-name": "Value name", "name-y-axis-scale": "Y axis scale", + "name-y-bucket-scale": "Y bucket scale", "placeholder-axis-label": "Auto", "placeholder-axis-width": "Auto", "placeholder-decimals": "Auto", @@ -9390,6 +9391,18 @@ "label-all": "All", "label-hidden": "Hidden", "label-single": "Single" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "Range within which the scale is linear", + "linear-threshold-label": "Linear threshold", + "linear-threshold-placeholder": "1", + "log-base-label": "Log base", + "scale-options": { + "label-auto": "Auto", + "label-linear": "Linear", + "label-log": "Log", + "label-symlog": "Symlog" + } } }, "help-modal": { From 0ec716a433c3f4e1304e2ec7e111eaf068936b33 Mon Sep 17 00:00:00 2001 From: Nathan Marrs Date: Thu, 18 Dec 2025 15:01:16 -0800 Subject: [PATCH 037/229] Embedded Dashboard Panels: Add Grafana Branding (#115198) * feat: add Grafana logo to embedded panels - Add Grafana logo watermark to solo panel view (embedded panels) - Logo appears in top-right corner with subtle background container - Logo hides on hover to avoid interfering with panel content - Uses React state to track hover for reliable behavior across nested elements * minor formatting * update changes to match public dashboards styling * match styles of public dashboards * feat: add responsive Grafana branding to embedded panels - Add 'Powered by Grafana' branding with text logo to solo panel view - Implement responsive scaling based on panel dimensions (0.6x to 1.0x) - Logo and text scale proportionally with panel size - Branding hides on hover to avoid interfering with panel content - Matches public dashboard branding pattern for consistency - Uses ResizeObserver for efficient responsive updates * feat: add Grafana branding to embedded solo panels - Add 'Powered by Grafana' branding with text logo to embedded panels - Create SoloPanelPageLogo component for reusable branding - Implement responsive scaling based on panel dimensions - Add hover-to-hide functionality to avoid content overlap - Logo scales between 0.6x and 1.0x based on panel size * refactor: move scale calculation into SoloPanelPageLogo component - Move responsive scale calculation logic from SoloPanelRenderer to SoloPanelPageLogo - Logo component now manages its own scaling based on container dimensions - Improves separation of concerns and component encapsulation * feat: add hideLogo query parameter to disable embedded panel branding - Add hideLogo query parameter support to SoloPanelPage - Logo can be hidden via ?hideLogo, ?hideLogo=true, or ?hideLogo=1 - Useful for customers who want to disable branding and for image rendering scenarios - Update Props interface to include hideLogo in queryParams type * feat: hide logo in panel image renderer URLs - Add hideLogo=true parameter to image renderer URLs in ShareLinkTab - Ensures logo is hidden when generating panel images through share feature - Update test to expect hideLogo=true in render URL * feat: hide logo in old dashboard sharing panel image URLs - Add hideLogo=true parameter to buildImageUrl in ShareModal utils - Ensures logo is hidden when generating panel images through old share modal - Update all ShareLink tests to expect hideLogo=true in render URLs * test: add comprehensive tests for SoloPanelPage and SoloPanelPageLogo - Add SoloPanelPageLogo tests covering rendering, hover behavior, theme selection, and scaling - Add SoloPanelPage tests covering logo visibility based on hideLogo prop - Test logo hiding functionality (most important behavior) - Test responsive scaling based on container dimensions - Test ResizeObserver integration - All 14 tests passing * refactor: centralize hideLogo handling in SoloPanelPageLogo Move hideLogo parsing and decision-making into SoloPanelPageLogo so SoloPanelPage/SoloPanelRenderer only pass through the raw query param value. * chore: clean up solo logo test and share link params Remove a duplicate SVG mock in SoloPanelPageLogo.test, and simplify ShareLinkTab image URL building without changing behavior. * chore: revert ShareLinkTab image query refactor Restore the previous image URL query-param mutation logic in ShareLinkTab to reduce risk. * chore: set hideLogo once for ShareLinkTab image URLs Avoid passing hideLogo twice when building the rendered image URL. * fix: handle boolean hideLogo query param in SoloPanelPageLogo Handle query params that are represented as booleans (e.g., ?hideLogo) and arrays, and avoid calling trim() on non-strings. * fix i18n * fix(dashboard-scene): address SoloPanelPageLogo review feedback Avoid double-scaling logo margin, clarify scaling comments, and extend tests for null/array values and ResizeObserver cleanup. * update margin left on logo to better match text spacing --- .../sharing/ShareLinkTab.test.tsx | 2 +- .../dashboard-scene/sharing/ShareLinkTab.tsx | 3 + .../solo/SoloPanelPage.test.tsx | 152 +++++++++ .../dashboard-scene/solo/SoloPanelPage.tsx | 67 ++-- .../solo/SoloPanelPageLogo.test.tsx | 291 ++++++++++++++++++ .../solo/SoloPanelPageLogo.tsx | 159 ++++++++++ .../components/ShareModal/ShareLink.test.tsx | 10 +- .../dashboard/components/ShareModal/utils.ts | 1 + public/locales/en-US/grafana.json | 3 + 9 files changed, 663 insertions(+), 25 deletions(-) create mode 100644 public/app/features/dashboard-scene/solo/SoloPanelPage.test.tsx create mode 100644 public/app/features/dashboard-scene/solo/SoloPanelPageLogo.test.tsx create mode 100644 public/app/features/dashboard-scene/solo/SoloPanelPageLogo.tsx diff --git a/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx b/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx index d50de744de1..ffdf1edd5dc 100644 --- a/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx @@ -89,7 +89,7 @@ describe('ShareLinkTab', () => { await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage }) ).toHaveAttribute( 'href', - 'http://dashboards.grafana.com/grafana/render/d-solo/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&panelId=A$panel-12&__feature.dashboardSceneSolo=true&width=1000&height=500&tz=Pacific%2FEaster' + 'http://dashboards.grafana.com/grafana/render/d-solo/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&panelId=A$panel-12&__feature.dashboardSceneSolo=true&hideLogo=true&width=1000&height=500&tz=Pacific%2FEaster' ); }); }); diff --git a/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx b/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx index 9d2daa5812b..cd70f0e2e83 100644 --- a/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx @@ -81,6 +81,9 @@ export class ShareLinkTab extends SceneObjectBase implements imageQueryParams['__feature.dashboardSceneSolo'] = true; } + // hide Grafana logo in the rendered image + urlParamsUpdate.hideLogo = 'true'; + const imageUrl = getDashboardUrl({ uid: dashboard.state.uid, currentQueryParams: window.location.search, diff --git a/public/app/features/dashboard-scene/solo/SoloPanelPage.test.tsx b/public/app/features/dashboard-scene/solo/SoloPanelPage.test.tsx new file mode 100644 index 00000000000..a570131e6f9 --- /dev/null +++ b/public/app/features/dashboard-scene/solo/SoloPanelPage.test.tsx @@ -0,0 +1,152 @@ +import { render, screen } from '@testing-library/react'; +import { useParams } from 'react-router-dom-v5-compat'; + +import { SceneTimeRange, VizPanel } from '@grafana/scenes'; + +import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageStateManager'; +import { DashboardScene } from '../scene/DashboardScene'; +import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; + +import { SoloPanelRenderer } from './SoloPanelPage'; + +// Mock dependencies +jest.mock('react-router-dom-v5-compat', () => ({ + useParams: jest.fn(), +})); + +jest.mock('../pages/DashboardScenePageStateManager', () => ({ + getDashboardScenePageStateManager: jest.fn(), +})); + +jest.mock('../scene/SoloPanelContext', () => ({ + SoloPanelContextProvider: ({ children }: { children: React.ReactNode }) =>
{children}
, + useDefineSoloPanelContext: jest.fn(() => ({})), +})); + +jest.mock('./SoloPanelPageLogo', () => ({ + shouldHideSoloPanelLogo: (hideLogo?: unknown) => { + if (hideLogo === undefined) { + return false; + } + if (hideLogo === true) { + return true; + } + if (hideLogo === false) { + return false; + } + if (Array.isArray(hideLogo)) { + hideLogo = hideLogo[0] ?? ''; + } + const normalized = String(hideLogo).trim().toLowerCase(); + return normalized !== 'false' && normalized !== '0'; + }, + SoloPanelPageLogo: ({ isHovered, hideLogo }: { isHovered: boolean; hideLogo?: unknown }) => { + if (hideLogo === true) { + return null; + } + if (hideLogo === false) { + return ( +
+ Logo +
+ ); + } + if (Array.isArray(hideLogo)) { + hideLogo = hideLogo[0] ?? ''; + } + if (hideLogo !== undefined) { + const normalized = String(hideLogo).trim().toLowerCase(); + if (normalized !== 'false' && normalized !== '0') { + return null; + } + } + return ( +
+ Logo +
+ ); + }, +})); + +describe('SoloPanelPage', () => { + const mockStateManager = { + useState: jest.fn(() => ({ + dashboard: null, + loadError: null, + })), + loadDashboard: jest.fn(), + clearState: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + (getDashboardScenePageStateManager as jest.Mock).mockReturnValue(mockStateManager); + (useParams as jest.Mock).mockReturnValue({ uid: 'test-uid', type: undefined, slug: undefined }); + }); + + describe('SoloPanelRenderer', () => { + const createMockDashboard = () => { + const panel = new VizPanel({ + title: 'Test Panel', + pluginId: 'table', + key: 'panel-1', + }); + + const dashboard = new DashboardScene({ + title: 'Test Dashboard', + uid: 'test-dash', + $timeRange: new SceneTimeRange({}), + body: DefaultGridLayoutManager.fromVizPanels([panel]), + }); + + // Mock the activate method + dashboard.activate = jest.fn(() => jest.fn()); + + // Mock useState to return the dashboard state object with required properties + dashboard.useState = jest.fn(() => ({ + controls: { + useState: jest.fn(() => ({ + refreshPicker: { + activate: jest.fn(() => jest.fn()), + }, + })), + }, + body: { + Component: () =>
Panel Content
, + }, + })) as unknown as typeof dashboard.useState; + + return dashboard; + }; + + it('should render the panel', () => { + const dashboard = createMockDashboard(); + render(); + + // The panel should be rendered (we can't easily test the actual panel content without more setup) + expect(screen.getByTestId('solo-panel-logo')).toBeInTheDocument(); + }); + + it('should render logo when hideLogo is false', () => { + const dashboard = createMockDashboard(); + render(); + + expect(screen.getByTestId('solo-panel-logo')).toBeInTheDocument(); + }); + + it('should not render logo when hideLogo is true', () => { + const dashboard = createMockDashboard(); + render(); + + expect(screen.queryByTestId('solo-panel-logo')).not.toBeInTheDocument(); + }); + + it('should initialize with isHovered as false', () => { + const dashboard = createMockDashboard(); + render(); + + const logo = screen.getByTestId('solo-panel-logo'); + expect(logo).toHaveAttribute('data-hovered', 'false'); + }); + }); +}); diff --git a/public/app/features/dashboard-scene/solo/SoloPanelPage.tsx b/public/app/features/dashboard-scene/solo/SoloPanelPage.tsx index 306776911de..65b7fbb443d 100644 --- a/public/app/features/dashboard-scene/solo/SoloPanelPage.tsx +++ b/public/app/features/dashboard-scene/solo/SoloPanelPage.tsx @@ -1,9 +1,9 @@ // Libraries import { css } from '@emotion/css'; -import { useEffect } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useParams } from 'react-router-dom-v5-compat'; -import { GrafanaTheme2 } from '@grafana/data'; +import { GrafanaTheme2, UrlQueryValue } from '@grafana/data'; import { t } from '@grafana/i18n'; import { UrlSyncContextProvider } from '@grafana/scenes'; import { Alert, Box, useStyles2 } from '@grafana/ui'; @@ -17,7 +17,10 @@ import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageSt import { DashboardScene } from '../scene/DashboardScene'; import { SoloPanelContextProvider, useDefineSoloPanelContext } from '../scene/SoloPanelContext'; -export interface Props extends GrafanaRouteComponentProps {} +import { SoloPanelPageLogo } from './SoloPanelPageLogo'; + +export interface Props + extends GrafanaRouteComponentProps {} /** * Used for iframe embedding and image rendering of single panels @@ -52,18 +55,28 @@ export function SoloPanelPage({ queryParams }: Props) { return ( - + ); } export default SoloPanelPage; -export function SoloPanelRenderer({ dashboard, panelId }: { dashboard: DashboardScene; panelId: string }) { +export function SoloPanelRenderer({ + dashboard, + panelId, + hideLogo, +}: { + dashboard: DashboardScene; + panelId: string; + hideLogo?: UrlQueryValue; +}) { const { controls, body } = dashboard.useState(); const refreshPicker = controls?.useState()?.refreshPicker; const styles = useStyles2(getStyles); const soloPanelContext = useDefineSoloPanelContext(panelId)!; + const [isHovered, setIsHovered] = useState(false); + const containerRef = useRef(null); useEffect(() => { const dashDeactivate = dashboard.activate(); @@ -76,11 +89,19 @@ export function SoloPanelRenderer({ dashboard, panelId }: { dashboard: Dashboard }, [dashboard, refreshPicker]); return ( -
+
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {renderHiddenVariables(dashboard)} - - - +
+ + + +
); } @@ -107,15 +128,23 @@ function renderHiddenVariables(dashboard: DashboardScene) { ); } -const getStyles = (theme: GrafanaTheme2) => ({ - container: css({ - position: 'fixed', - bottom: 0, - right: 0, - margin: 0, - left: 0, - top: 0, +const getStyles = (theme: GrafanaTheme2) => { + const panelWrapper = css({ width: '100%', height: '100%', - }), -}); + }); + + return { + container: css({ + position: 'fixed', + bottom: 0, + right: 0, + margin: 0, + left: 0, + top: 0, + width: '100%', + height: '100%', + }), + panelWrapper, + }; +}; diff --git a/public/app/features/dashboard-scene/solo/SoloPanelPageLogo.test.tsx b/public/app/features/dashboard-scene/solo/SoloPanelPageLogo.test.tsx new file mode 100644 index 00000000000..bbfe0c5bcdd --- /dev/null +++ b/public/app/features/dashboard-scene/solo/SoloPanelPageLogo.test.tsx @@ -0,0 +1,291 @@ +import { render, screen } from '@testing-library/react'; +import { createRef } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; + +import { shouldHideSoloPanelLogo, SoloPanelPageLogo } from './SoloPanelPageLogo'; + +// Mock the theme hook +const mockUseTheme2 = jest.fn(); +const mockUseStyles2 = jest.fn((fn) => fn({} as GrafanaTheme2)); + +jest.mock('@grafana/ui', () => ({ + ...jest.requireActual('@grafana/ui'), + useTheme2: () => mockUseTheme2(), + useStyles2: (fn: (theme: GrafanaTheme2) => Record) => mockUseStyles2(fn), +})); + +// Mock the logo images for dark and light modes +jest.mock('img/grafana_text_logo_dark.svg', () => 'grafana-text-logo-dark.svg'); +jest.mock('img/grafana_text_logo_light.svg', () => 'grafana-text-logo-light.svg'); + +// Mock ResizeObserver +global.ResizeObserver = jest.fn().mockImplementation((callback) => { + return { + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + // Helper to trigger resize + trigger: (width: number, height: number) => { + callback([{ contentRect: { width, height } }]); + }, + }; +}); + +// Helper function to assign a mock div to a ref +function assignMockDivToRef(ref: React.RefObject, mockDiv: HTMLDivElement) { + // Use type assertion to bypass readonly restriction in tests + (ref as { current: HTMLDivElement | null }).current = mockDiv; +} + +describe('SoloPanelPageLogo', () => { + describe('shouldHideSoloPanelLogo', () => { + it('treats null as false', () => { + expect(shouldHideSoloPanelLogo(null)).toBe(false); + }); + + it('treats presence (empty string) as true', () => { + expect(shouldHideSoloPanelLogo('')).toBe(true); + }); + + it('treats true/1 as true', () => { + expect(shouldHideSoloPanelLogo('true')).toBe(true); + expect(shouldHideSoloPanelLogo('1')).toBe(true); + expect(shouldHideSoloPanelLogo(' TRUE ')).toBe(true); + }); + + it('treats false/0 as false', () => { + expect(shouldHideSoloPanelLogo('false')).toBe(false); + expect(shouldHideSoloPanelLogo('0')).toBe(false); + expect(shouldHideSoloPanelLogo(' FALSE ')).toBe(false); + }); + + it('treats boolean true as true and boolean false as false', () => { + expect(shouldHideSoloPanelLogo(true)).toBe(true); + expect(shouldHideSoloPanelLogo(false)).toBe(false); + }); + + it('treats undefined as false', () => { + expect(shouldHideSoloPanelLogo(undefined)).toBe(false); + }); + + it('handles array values (uses the first value)', () => { + expect(shouldHideSoloPanelLogo([''])).toBe(true); + expect(shouldHideSoloPanelLogo(['true'])).toBe(true); + expect(shouldHideSoloPanelLogo(['1'])).toBe(true); + expect(shouldHideSoloPanelLogo(['false'])).toBe(false); + expect(shouldHideSoloPanelLogo(['0'])).toBe(false); + expect(shouldHideSoloPanelLogo(['false', 'true'])).toBe(false); + }); + }); + + const mockTheme = { + isDark: false, + colors: { + background: { primary: '#ffffff' }, + border: { weak: '#e0e0e0' }, + text: { secondary: '#666666' }, + }, + shape: { radius: { default: '4px' } }, + shadows: { z3: '0 2px 4px rgba(0,0,0,0.1)' }, + typography: { body: { fontSize: '14px' } }, + spacing: jest.fn((n: number) => `${n * 8}px`), + transitions: { + handleMotion: jest.fn(() => ({})), + }, + } as unknown as GrafanaTheme2; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseTheme2.mockReturnValue({ + ...mockTheme, + isDark: false, + }); + mockUseStyles2.mockImplementation((fn) => fn(mockTheme)); + }); + + it('should render the logo component', () => { + const containerRef = createRef(); + const mockDiv = document.createElement('div'); + mockDiv.getBoundingClientRect = jest.fn(() => ({ + width: 800, + height: 600, + top: 0, + left: 0, + bottom: 600, + right: 800, + x: 0, + y: 0, + toJSON: jest.fn(), + })); + + assignMockDivToRef(containerRef, mockDiv); + + render(); + + expect(screen.getByText('Powered by')).toBeInTheDocument(); + expect(screen.getByAltText('Grafana')).toBeInTheDocument(); + }); + + it('should hide logo when isHovered is true', () => { + const containerRef = createRef(); + const mockDiv = document.createElement('div'); + mockDiv.getBoundingClientRect = jest.fn(() => ({ + width: 800, + height: 600, + top: 0, + left: 0, + bottom: 600, + right: 800, + x: 0, + y: 0, + toJSON: jest.fn(), + })); + assignMockDivToRef(containerRef, mockDiv); + + render(); + + // The logo should still be in the DOM but with reduced opacity + const poweredByText = screen.getByText('Powered by'); + expect(poweredByText).toBeInTheDocument(); + // The logoHidden class should be applied (we can't easily test the class name without more setup) + }); + + it('should show logo when isHovered is false', () => { + const containerRef = createRef(); + const mockDiv = document.createElement('div'); + mockDiv.getBoundingClientRect = jest.fn(() => ({ + width: 800, + height: 600, + top: 0, + left: 0, + bottom: 600, + right: 800, + x: 0, + y: 0, + toJSON: jest.fn(), + })); + assignMockDivToRef(containerRef, mockDiv); + + render(); + + // The logo should be visible + expect(screen.getByText('Powered by')).toBeInTheDocument(); + expect(screen.getByAltText('Grafana')).toBeInTheDocument(); + }); + + it('should use dark logo in dark theme', () => { + const containerRef = createRef(); + const mockDiv = document.createElement('div'); + mockDiv.getBoundingClientRect = jest.fn(() => ({ + width: 800, + height: 600, + top: 0, + left: 0, + bottom: 600, + right: 800, + x: 0, + y: 0, + toJSON: jest.fn(), + })); + assignMockDivToRef(containerRef, mockDiv); + + mockUseTheme2.mockReturnValue({ + ...mockTheme, + isDark: true, + }); + + render(); + + const logo = screen.getByAltText('Grafana'); + expect(logo).toHaveAttribute('src', 'grafana-text-logo-light.svg'); + }); + + it('should use correct logo based on theme', () => { + const containerRef = createRef(); + const mockDiv = document.createElement('div'); + mockDiv.getBoundingClientRect = jest.fn(() => ({ + width: 800, + height: 600, + top: 0, + left: 0, + bottom: 600, + right: 800, + x: 0, + y: 0, + toJSON: jest.fn(), + })); + assignMockDivToRef(containerRef, mockDiv); + + // The beforeEach sets isDark: false by default, so this should work + // But the previous test might have changed it, so let's ensure it's reset + mockUseTheme2.mockClear(); + mockUseTheme2.mockReturnValue({ + ...mockTheme, + isDark: false, + }); + + render(); + + const logo = screen.getByAltText('Grafana'); + // Verify logo is rendered (the exact src depends on theme, which is tested in other tests) + expect(logo).toBeInTheDocument(); + expect(logo).toHaveAttribute('src'); + }); + + it('should apply scaling styles based on container dimensions', () => { + const containerRef = createRef(); + const mockDiv = document.createElement('div'); + mockDiv.getBoundingClientRect = jest.fn(() => ({ + width: 400, + height: 300, + top: 0, + left: 0, + bottom: 300, + right: 400, + x: 0, + y: 0, + toJSON: jest.fn(), + })); + assignMockDivToRef(containerRef, mockDiv); + + render(); + + // Find the logo container by looking for the "Powered by" text's parent + const poweredByText = screen.getByText('Powered by'); + const logoContainer = poweredByText.parentElement as HTMLElement; + expect(logoContainer).toBeInTheDocument(); + // Check that inline styles are applied (scaling should be between 0.6 and 1.0) + expect(logoContainer.style.fontSize).toBeTruthy(); + expect(logoContainer.style.top).toBeTruthy(); + expect(logoContainer.style.right).toBeTruthy(); + }); + + it('should observe container resize', () => { + const containerRef = createRef(); + const mockDiv = document.createElement('div'); + mockDiv.getBoundingClientRect = jest.fn(() => ({ + width: 800, + height: 600, + top: 0, + left: 0, + bottom: 600, + right: 800, + x: 0, + y: 0, + toJSON: jest.fn(), + })); + assignMockDivToRef(containerRef, mockDiv); + + const { unmount } = render( + + ); + + expect(ResizeObserver).toHaveBeenCalled(); + const resizeObserverInstance = (ResizeObserver as jest.Mock).mock.results[0].value; + expect(resizeObserverInstance.observe).toHaveBeenCalledWith(mockDiv); + + unmount(); + expect(resizeObserverInstance.disconnect).toHaveBeenCalled(); + }); +}); diff --git a/public/app/features/dashboard-scene/solo/SoloPanelPageLogo.tsx b/public/app/features/dashboard-scene/solo/SoloPanelPageLogo.tsx new file mode 100644 index 00000000000..43889049807 --- /dev/null +++ b/public/app/features/dashboard-scene/solo/SoloPanelPageLogo.tsx @@ -0,0 +1,159 @@ +import { css, cx } from '@emotion/css'; +import { useEffect, useState } from 'react'; + +import { GrafanaTheme2, UrlQueryValue } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; +import { useStyles2, useTheme2 } from '@grafana/ui'; +import grafanaTextLogoDarkSvg from 'img/grafana_text_logo_dark.svg'; +import grafanaTextLogoLightSvg from 'img/grafana_text_logo_light.svg'; + +interface SoloPanelPageLogoProps { + containerRef: React.RefObject; + isHovered: boolean; + hideLogo?: UrlQueryValue; +} + +export function shouldHideSoloPanelLogo(hideLogo?: UrlQueryValue): boolean { + if (hideLogo === undefined || hideLogo === null) { + return false; + } + + // React-router / locationSearchToObject can represent a "present but no value" query param as boolean true. + if (hideLogo === true) { + return true; + } + + if (hideLogo === false) { + return false; + } + + const value = Array.isArray(hideLogo) ? String(hideLogo[0] ?? '') : String(hideLogo); + + // Treat presence as "true", except explicit disable values. + // Examples: + // - ?hideLogo => hide + // - ?hideLogo=true => hide + // - ?hideLogo=1 => hide + // - ?hideLogo=false => show + // - ?hideLogo=0 => show + const normalized = value.trim().toLowerCase(); + return normalized !== 'false' && normalized !== '0'; +} + +export function SoloPanelPageLogo({ containerRef, isHovered, hideLogo }: SoloPanelPageLogoProps) { + const shouldHide = shouldHideSoloPanelLogo(hideLogo); + const [scale, setScale] = useState(1); + const styles = useStyles2(getStyles); + const theme = useTheme2(); + const grafanaLogo = theme.isDark ? grafanaTextLogoLightSvg : grafanaTextLogoDarkSvg; + + // Calculate responsive scale based on panel dimensions + useEffect(() => { + const updateScale = () => { + if (!containerRef.current) { + return; + } + + const { width, height } = containerRef.current.getBoundingClientRect(); + // Use the smaller dimension to ensure it scales appropriately for both wide and tall panels + const minDimension = Math.min(width, height); + + // Base scale calculation: scales from 0.6 (for small panels ~200px) up to 1.0 when the smaller dimension is ~800px + // Clamp to a maximum of 1.0 for larger panels + const baseScale = Math.max(0.6, Math.min(1.0, 0.6 + (minDimension - 200) / 600)); + + // Also consider width specifically for very wide but short panels; reaches 1.0 when width is ~1000px + const widthScale = Math.max(0.6, Math.min(1.0, 0.6 + (width - 200) / 800)); + + // Use the average of both for balanced scaling; panels around 1000x1000px (or larger in both dimensions) reach a scale of 1.0 + const finalScale = Math.min(1.0, (baseScale + widthScale) / 2); + setScale(finalScale); + }; + + updateScale(); + + const resizeObserver = new ResizeObserver(updateScale); + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + + return () => { + resizeObserver.disconnect(); + }; + }, [containerRef]); + + if (shouldHide) { + return null; + } + + return ( +
+ + Powered by + + Grafana +
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => { + const logoContainer = css({ + position: 'absolute', + // top, right, and padding will be set via inline styles for scaling + backgroundColor: theme.colors.background.primary, + borderRadius: theme.shape.radius.default, + opacity: 0.9, + pointerEvents: 'none', + zIndex: 1000, + display: 'flex', + alignItems: 'center', + boxShadow: theme.shadows.z3, + border: `1px solid ${theme.colors.border.weak}`, + // Base font size - will be scaled via inline style + fontSize: theme.typography.body.fontSize, + lineHeight: 1.2, + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: 'opacity 0.2s ease-in-out', + }, + }); + + const logoHidden = css({ + opacity: 0, + }); + + const text = css({ + color: theme.colors.text.secondary, + // fontSize will be inherited from parent container's scale + lineHeight: 1.2, + display: 'block', + }); + + const logo = css({ + // height will be set via inline style (16px * scale) to scale with panel size + display: 'block', + flexShrink: 0, + }); + + return { + logoContainer, + logoHidden, + text, + logo, + }; +}; diff --git a/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx b/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx index eef8b965a98..8bc902b6d0d 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx @@ -106,7 +106,7 @@ describe('ShareModal', () => { render(); const base = 'http://dashboards.grafana.com/render/d-solo/abcdefghi/my-dash'; - const params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&scale=1&tz=UTC'; + const params = '?from=1000&to=2000&orgId=1&panelId=22&hideLogo=true&width=1000&height=500&scale=1&tz=UTC'; expect( await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage }) ).toHaveAttribute('href', base + params); @@ -117,7 +117,7 @@ describe('ShareModal', () => { render(); const base = 'http://dashboards.grafana.com/render/dashboard-solo/script/my-dash.js'; - const params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&scale=1&tz=UTC'; + const params = '?from=1000&to=2000&orgId=1&panelId=22&hideLogo=true&width=1000&height=500&scale=1&tz=UTC'; expect( await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage }) ).toHaveAttribute('href', base + params); @@ -154,7 +154,7 @@ describe('ShareModal', () => { await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage }) ).toHaveAttribute( 'href', - base + path + '?from=1000&to=2000&orgId=1&panelId=1&width=1000&height=500&scale=1&tz=UTC' + base + path + '?from=1000&to=2000&orgId=1&panelId=1&hideLogo=true&width=1000&height=500&scale=1&tz=UTC' ); }); @@ -172,7 +172,7 @@ describe('ShareModal', () => { render(); const base = 'http://dashboards.grafana.com/render/d-solo/abcdefghi/my-dash'; - const params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&scale=1&tz=UTC'; + const params = '?from=1000&to=2000&orgId=1&panelId=22&hideLogo=true&width=1000&height=500&scale=1&tz=UTC'; expect( await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage }) ).toHaveAttribute('href', base + params); @@ -213,7 +213,7 @@ describe('when appUrl is set in the grafana config', () => { await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage }) ).toHaveAttribute( 'href', - `http://dashboards.grafana.com/render/d-solo/${mockDashboard.uid}?orgId=1&from=1000&to=2000&panelId=${mockPanel.id}&width=1000&height=500&scale=1&tz=UTC` + `http://dashboards.grafana.com/render/d-solo/${mockDashboard.uid}?orgId=1&from=1000&to=2000&panelId=${mockPanel.id}&hideLogo=true&width=1000&height=500&scale=1&tz=UTC` ); }); }); diff --git a/public/app/features/dashboard/components/ShareModal/utils.ts b/public/app/features/dashboard/components/ShareModal/utils.ts index e60deafe3a5..c79203d5872 100644 --- a/public/app/features/dashboard/components/ShareModal/utils.ts +++ b/public/app/features/dashboard/components/ShareModal/utils.ts @@ -142,6 +142,7 @@ export function buildImageUrl( let imageUrl = soloUrl.replace(config.appSubUrl + '/dashboard-solo/', config.appSubUrl + '/render/dashboard-solo/'); imageUrl = imageUrl.replace(config.appSubUrl + '/d-solo/', config.appSubUrl + '/render/d-solo/'); imageUrl += + `&hideLogo=true` + `&width=${config.rendererDefaultImageWidth}` + `&height=${config.rendererDefaultImageHeight}` + `&scale=${config.rendererDefaultImageScale}` + diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 0de5ac009db..febd4c0442e 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -7166,6 +7166,9 @@ "time-range-label": "Lock time range" } }, + "embedded-panel": { + "powered-by": "Powered by" + }, "empty-list-cta": { "pro-tip": "ProTip: {{proTip}}" }, From 606a59584a5fce12fd48f9e4153e1e4b01cb7963 Mon Sep 17 00:00:00 2001 From: Collin Fingar Date: Thu, 18 Dec 2025 18:18:24 -0500 Subject: [PATCH 038/229] Saved Queries: Pass editor ref for dynamic dropdown display (#114321) * Saved Queries: Pass editor ref for dynamic dropdown display * Updated docs per feedback * Update docs/sources/visualizations/dashboards/build-dashboards/annotate-visualizations/index.md Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> * Update docs/sources/visualizations/dashboards/build-dashboards/annotate-visualizations/index.md Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> * Update docs/sources/visualizations/dashboards/build-dashboards/create-dashboard/index.md Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> * Update docs/sources/visualizations/dashboards/build-dashboards/create-dashboard/index.md Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> * Update docs/sources/visualizations/explore/get-started-with-explore.md Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> * Update docs/sources/visualizations/panels-visualizations/query-transform-data/_index.md Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> * Update docs/sources/visualizations/panels-visualizations/query-transform-data/_index.md Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> * Update docs/sources/visualizations/panels-visualizations/query-transform-data/_index.md Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> * Update docs/sources/visualizations/panels-visualizations/query-transform-data/_index.md Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> * Update docs/sources/visualizations/panels-visualizations/query-transform-data/_index.md Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> --------- Co-authored-by: Nathan Marrs Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> --- .../annotate-visualizations/index.md | 4 ++-- .../build-dashboards/create-dashboard/index.md | 4 ++-- .../explore/get-started-with-explore.md | 5 +++-- .../panel-editor-overview/index.md | 5 +++-- .../query-transform-data/_index.md | 10 +++++----- .../explore/QueryLibrary/QueryLibraryContext.tsx | 3 ++- .../query/components/QueryEditorRow.test.tsx | 3 +-- .../features/query/components/QueryEditorRow.tsx | 16 +++++++++++++--- 8 files changed, 31 insertions(+), 19 deletions(-) diff --git a/docs/sources/visualizations/dashboards/build-dashboards/annotate-visualizations/index.md b/docs/sources/visualizations/dashboards/build-dashboards/annotate-visualizations/index.md index f356d51c4a5..5458f90281e 100644 --- a/docs/sources/visualizations/dashboards/build-dashboards/annotate-visualizations/index.md +++ b/docs/sources/visualizations/dashboards/build-dashboards/annotate-visualizations/index.md @@ -163,9 +163,9 @@ To add a new annotation query to a dashboard, follow these steps: 1. To create a query, do one of the following: - Write or construct a query in the query language of your data source. The annotation query options are different for each data source. For information about annotations in a specific data source, refer to the specific [data source](ref:data-source) topic. - - Click **Replace with saved query** to reuse a [saved query](ref:saved-queries). + - Open the **Saved queries** drop-down menu and click **Replace query** to reuse a [saved query](ref:saved-queries). -1. (Optional) To [save the query](ref:save-query) for reuse, click the **Save query** button (or icon). +1. (Optional) To [save the query](ref:save-query) for reuse, open the **Saved queries** drop-down menu and click the **Save query** option. 1. (Optional) Click **Test annotation query** to ensure that the query is working properly. 1. (Optional) To add subsequent queries, click **+ Add query** or **+ Add from saved queries**, and test them as many times as needed. diff --git a/docs/sources/visualizations/dashboards/build-dashboards/create-dashboard/index.md b/docs/sources/visualizations/dashboards/build-dashboards/create-dashboard/index.md index feb6573c3e9..3d5e765e0cd 100644 --- a/docs/sources/visualizations/dashboards/build-dashboards/create-dashboard/index.md +++ b/docs/sources/visualizations/dashboards/build-dashboards/create-dashboard/index.md @@ -125,9 +125,9 @@ Dashboards and panels allow you to show your data in visual form. Each panel nee 1. To create a query, do one of the following: - Write or construct a query in the query language of your data source. - - Click **Replace with saved query** to reuse a [saved query](ref:saved-queries). + - Open the **Saved queries** drop-down menu and click **Replace query** to reuse a [saved query](ref:saved-queries). -1. (Optional) To [save the query](ref:save-query) for reuse, click the **Save query** button (or icon). +1. (Optional) To [save the query](ref:save-query) for reuse, open the **Saved queries** drop-down menu and click the **Save query** option. 1. Click **Refresh** to query the data source. 1. (Optional) To add subsequent queries, click **+ Add query** or **+ Add from saved queries**, and refresh the data source as many times as needed. diff --git a/docs/sources/visualizations/explore/get-started-with-explore.md b/docs/sources/visualizations/explore/get-started-with-explore.md index b59ac68c686..41f1f8c5c01 100644 --- a/docs/sources/visualizations/explore/get-started-with-explore.md +++ b/docs/sources/visualizations/explore/get-started-with-explore.md @@ -71,8 +71,9 @@ Explore consists of a toolbar, outline, query editor, the ability to add multipl - **Run query** - Click to run your query. - **Query editor** - Interface where you construct the query for a specific data source. Query editor elements differ based on data source. In order to run queries across multiple data sources you need to select **Mixed** from the data source picker. - - **Save query** - To [save the query](ref:save-query) for reuse, click the **Save query** button (or icon). - - **Replace with saved query** - Reuse a saved query. + - **Saved queries**: + - **Save query** - To [save the query](ref:save-query) for reuse, click the **Save query** button (or icon). + - **Replace query** - Reuse a saved query. - **+ Add query** - Add an additional query. - **+ Add from saved queries** - Add an additional query by reusing a saved query. diff --git a/docs/sources/visualizations/panels-visualizations/panel-editor-overview/index.md b/docs/sources/visualizations/panels-visualizations/panel-editor-overview/index.md index 4afbd8f71a6..f219f10a68a 100644 --- a/docs/sources/visualizations/panels-visualizations/panel-editor-overview/index.md +++ b/docs/sources/visualizations/panels-visualizations/panel-editor-overview/index.md @@ -88,8 +88,9 @@ The data section contains tabs where you enter queries, transform your data, and - **Queries** - Select your data source. You can also set or update the data source in existing dashboards using the drop-down menu in the **Queries** tab. - - **Save query** - To [save the query](ref:save-query) for reuse, click the **Save query** button (or icon). - - **Replace with saved query** - Reuse a saved query. + - **Saved queries**: + - **Save query** - To [save the query](ref:save-query) for reuse, click the **Save query** button (or icon). + - **Replace query** - Reuse a saved query. - **+ Add query** - Add an additional query. - **+ Add from saved queries** - Add an additional query by reusing a saved query. diff --git a/docs/sources/visualizations/panels-visualizations/query-transform-data/_index.md b/docs/sources/visualizations/panels-visualizations/query-transform-data/_index.md index 5b121028404..0aafe41f524 100644 --- a/docs/sources/visualizations/panels-visualizations/query-transform-data/_index.md +++ b/docs/sources/visualizations/panels-visualizations/query-transform-data/_index.md @@ -156,11 +156,11 @@ In the **Saved queries** drawer, you can: - Edit a query title, description, tags, or the availability of the query to other users in your organization. By default, saved queries are locked for editing. - When you access the **Saved queries** drawer from Explore, you can use the **Edit in Explore** option to edit the body of a query. -To access your saved queries, click **+ Add from saved queries** or **Replace with saved query** in the query editor: +To access your saved queries, click **+ Add from saved queries** or open the **Saved queries** drop-down menu and click **Replace query** in the query editor: {{< figure src="/media/docs/grafana/dashboards/screenshot-use-saved-queries-v12.3.png" max-width="750px" alt="Access saved queries" >}} -Clicking **+ Add from saved queries** adds an additional query, while clicking **Replace with saved query** updates your existing query. +Clicking **+ Add from saved queries** adds an additional query, while clicking **Replace query** in the **Saved queries** drop-down menu updates your existing query. {{< admonition type="note" >}} Users with Admin and Editor roles can create and save queries for reuse. @@ -172,7 +172,7 @@ Viewers can only reuse queries. To save a query you've created: -1. From the query editor, click the **Save query** icon: +1. From the query editor, open the **Saved queries** drop-down menu and click the **Save query** option: {{< figure src="/media/docs/grafana/panels-visualizations/screenshot-save-query-v12.2.png" max-width="750px" alt="Save a query" >}} @@ -227,7 +227,7 @@ To add a query, follow these steps: 1. To create a query, do one of the following: - Write or construct a query in the query language of your data source. - - Click **Replace with saved query** to reuse a saved query. + - Open the **Saved queries** drop-down menu and click **Replace query** to reuse a saved query. {{< admonition type="note" >}} [Saved queries](#saved-queries) is currently in [public preview](https://grafana.com/docs/release-life-cycle/). Grafana Labs offers limited support, and breaking changes might occur prior to the feature being made generally available. @@ -235,7 +235,7 @@ To add a query, follow these steps: This feature is only available on Grafana Enterprise and Grafana Cloud. {{< /admonition >}} -1. (Optional) To [save the query](#save-a-query) for reuse, click the **Save query** button (or icon). +1. (Optional) To [save the query](#save-a-query) for reuse, click the **Save query** option in the **Saved queries** drop-down menu. 1. (Optional) Click **+ Add query** or **Add from saved queries** to add more queries as needed. 1. Click **Run queries**. diff --git a/public/app/features/explore/QueryLibrary/QueryLibraryContext.tsx b/public/app/features/explore/QueryLibrary/QueryLibraryContext.tsx index 50ebe16e49c..6138829632a 100644 --- a/public/app/features/explore/QueryLibrary/QueryLibraryContext.tsx +++ b/public/app/features/explore/QueryLibrary/QueryLibraryContext.tsx @@ -43,7 +43,8 @@ export type QueryLibraryContextType = { app?: CoreApp, onUpdateSuccess?: () => void, onSelectQuery?: (query: DataQuery) => void, - datasourceFilters?: string[] + datasourceFilters?: string[], + parentRef?: React.RefObject ) => ReactNode; /** diff --git a/public/app/features/query/components/QueryEditorRow.test.tsx b/public/app/features/query/components/QueryEditorRow.test.tsx index f839e570819..13be75a8fcb 100644 --- a/public/app/features/query/components/QueryEditorRow.test.tsx +++ b/public/app/features/query/components/QueryEditorRow.test.tsx @@ -461,8 +461,7 @@ describe('QueryEditorRow', () => { render(); await waitFor(() => { - expect(screen.queryByText('Save query')).not.toBeInTheDocument(); - expect(screen.queryByText('Replace with saved query')).not.toBeInTheDocument(); + expect(screen.queryByText('Saved queries')).not.toBeInTheDocument(); }); }); diff --git a/public/app/features/query/components/QueryEditorRow.tsx b/public/app/features/query/components/QueryEditorRow.tsx index b474d0293f9..5cc7f3467d3 100644 --- a/public/app/features/query/components/QueryEditorRow.tsx +++ b/public/app/features/query/components/QueryEditorRow.tsx @@ -1,7 +1,7 @@ import classNames from 'classnames'; import { cloneDeep, filter, uniqBy, uniqueId } from 'lodash'; import pluralize from 'pluralize'; -import { PureComponent, ReactNode, type JSX } from 'react'; +import { PureComponent, ReactNode, type JSX, createRef } from 'react'; import { CoreApp, @@ -88,6 +88,7 @@ interface State { export class QueryEditorRow extends PureComponent, State> { dataSourceSrv = getDataSourceSrv(); id = ''; + editorRef = createRef(); state: State = { datasource: null, @@ -419,6 +420,7 @@ export class QueryEditorRow extends PureComponent )} @@ -542,7 +544,7 @@ export class QueryEditorRow extends PureComponent +
{queryLibraryRef && ( void; onSelectQuery: (query: DataQuery) => void; datasourceFilters: string[]; + parentRef: React.RefObject; }) { const { renderSavedQueryButtons } = useQueryLibraryContext(); - return renderSavedQueryButtons(props.query, props.app, props.onUpdateSuccess, props.onSelectQuery); + return renderSavedQueryButtons( + props.query, + props.app, + props.onUpdateSuccess, + props.onSelectQuery, + undefined, + props.parentRef + ); } // Will render editing header only if query library is enabled From 99f5f14de764bf2d09357fb81b0af82a39c7ddc0 Mon Sep 17 00:00:00 2001 From: Will Assis <35489495+gassiss@users.noreply.github.com> Date: Thu, 18 Dec 2025 18:35:32 -0500 Subject: [PATCH 039/229] unified-storage: move rvmanager into its own package (#115445) * unified-storage: move rvmanager into its own package so it can be reused with sqlkv later --- pkg/storage/unified/sql/backend.go | 9 +- pkg/storage/unified/sql/backend_test.go | 20 +++++ pkg/storage/unified/sql/bulk.go | 4 +- pkg/storage/unified/sql/list_iterator_test.go | 3 +- pkg/storage/unified/sql/queries.go | 72 +--------------- pkg/storage/unified/sql/queries_test.go | 23 ++--- .../data/resource_history_update_rv.sql | 0 .../data/resource_update_rv.sql | 0 .../data/resource_version_get.sql | 0 .../data/resource_version_insert.sql | 0 .../data/resource_version_update.sql | 0 pkg/storage/unified/sql/rvmanager/queries.go | 84 +++++++++++++++++++ .../unified/sql/{ => rvmanager}/rv_manager.go | 51 ++++++----- .../sql/{ => rvmanager}/rv_manager_test.go | 2 +- .../unified/sql/rvmanager/templates.go | 30 +++++++ 15 files changed, 186 insertions(+), 112 deletions(-) rename pkg/storage/unified/sql/{ => rvmanager}/data/resource_history_update_rv.sql (100%) rename pkg/storage/unified/sql/{ => rvmanager}/data/resource_update_rv.sql (100%) rename pkg/storage/unified/sql/{ => rvmanager}/data/resource_version_get.sql (100%) rename pkg/storage/unified/sql/{ => rvmanager}/data/resource_version_insert.sql (100%) rename pkg/storage/unified/sql/{ => rvmanager}/data/resource_version_update.sql (100%) create mode 100644 pkg/storage/unified/sql/rvmanager/queries.go rename pkg/storage/unified/sql/{ => rvmanager}/rv_manager.go (89%) rename pkg/storage/unified/sql/{ => rvmanager}/rv_manager_test.go (99%) create mode 100644 pkg/storage/unified/sql/rvmanager/templates.go diff --git a/pkg/storage/unified/sql/backend.go b/pkg/storage/unified/sql/backend.go index ebd04ce4a30..a129a01727a 100644 --- a/pkg/storage/unified/sql/backend.go +++ b/pkg/storage/unified/sql/backend.go @@ -31,6 +31,7 @@ import ( "github.com/grafana/grafana/pkg/storage/unified/resourcepb" "github.com/grafana/grafana/pkg/storage/unified/sql/db" "github.com/grafana/grafana/pkg/storage/unified/sql/dbutil" + "github.com/grafana/grafana/pkg/storage/unified/sql/rvmanager" "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" "github.com/grafana/grafana/pkg/util/debouncer" ) @@ -126,7 +127,7 @@ type backend struct { notifier eventNotifier // resource version manager - rvManager *resourceVersionManager + rvManager *rvmanager.ResourceVersionManager // testing simulatedNetworkLatency time.Duration @@ -163,7 +164,7 @@ func (b *backend) initLocked(ctx context.Context) error { } // Initialize ResourceVersionManager - rvManager, err := NewResourceVersionManager(ResourceManagerOptions{ + rvManager, err := rvmanager.NewResourceVersionManager(rvmanager.ResourceManagerOptions{ Dialect: b.dialect, DB: b.db, }) @@ -928,12 +929,12 @@ func (b *backend) listLatestRVs(ctx context.Context) (groupResourceRV, error) { func (b *backend) fetchLatestRV(ctx context.Context, x db.ContextExecer, d sqltemplate.Dialect, group, resource string) (int64, error) { ctx, span := tracer.Start(ctx, "sql.backend.fetchLatestRV") defer span.End() - res, err := dbutil.QueryRow(ctx, x, sqlResourceVersionGet, sqlResourceVersionGetRequest{ + res, err := dbutil.QueryRow(ctx, x, rvmanager.SqlResourceVersionGet, rvmanager.SqlResourceVersionGetRequest{ SQLTemplate: sqltemplate.New(d), Group: group, Resource: resource, ReadOnly: true, - Response: new(resourceVersionResponse), + Response: new(rvmanager.ResourceVersionResponse), }) if errors.Is(err, sql.ErrNoRows) { return 1, nil diff --git a/pkg/storage/unified/sql/backend_test.go b/pkg/storage/unified/sql/backend_test.go index f7ecd755f32..2f8f9fcb060 100644 --- a/pkg/storage/unified/sql/backend_test.go +++ b/pkg/storage/unified/sql/backend_test.go @@ -40,6 +40,26 @@ type testBackend struct { test.TestDBProvider } +func expectSuccessfulResourceVersionLock(t *testing.T, dbp test.TestDBProvider, rv int64, timestamp int64) { + dbp.SQLMock.ExpectQuery("select resource_version, unix_timestamp for update"). + WillReturnRows(sqlmock.NewRows([]string{"resource_version", "unix_timestamp"}). + AddRow(rv, timestamp)) +} + +func expectSuccessfulResourceVersionSaveRV(t *testing.T, dbp test.TestDBProvider) { + dbp.SQLMock.ExpectExec("update resource set resource_version").WillReturnResult(sqlmock.NewResult(1, 1)) + dbp.SQLMock.ExpectExec("update resource_history set resource_version").WillReturnResult(sqlmock.NewResult(1, 1)) + dbp.SQLMock.ExpectExec("update resource_version set resource_version").WillReturnResult(sqlmock.NewResult(1, 1)) +} + +func expectSuccessfulResourceVersionExec(t *testing.T, dbp test.TestDBProvider, cbs ...func()) { + for _, cb := range cbs { + cb() + } + expectSuccessfulResourceVersionLock(t, dbp, 100, 200) + expectSuccessfulResourceVersionSaveRV(t, dbp) +} + func (b testBackend) ExecWithResult(expectedSQL string, lastInsertID int64, rowsAffected int64) { b.SQLMock.ExpectExec(expectedSQL).WillReturnResult(sqlmock.NewResult(lastInsertID, rowsAffected)) } diff --git a/pkg/storage/unified/sql/bulk.go b/pkg/storage/unified/sql/bulk.go index 6580975a764..4037c74f0dd 100644 --- a/pkg/storage/unified/sql/bulk.go +++ b/pkg/storage/unified/sql/bulk.go @@ -281,13 +281,13 @@ func (b *backend) processBulkWithTx(ctx context.Context, tx db.Tx, setting resou } if b.dialect.DialectName() == "sqlite" { - nextRV, err := b.rvManager.lock(ctx, tx, key.Group, key.Resource) + nextRV, err := b.rvManager.Lock(ctx, tx, key.Group, key.Resource) if err != nil { b.log.Error("error locking RV", "error", err, "key", resource.NSGR(key)) } else { b.log.Info("successfully locked RV", "nextRV", nextRV, "key", resource.NSGR(key)) // Save the incremented RV - if err := b.rvManager.saveRV(ctx, tx, key.Group, key.Resource, nextRV); err != nil { + if err := b.rvManager.SaveRV(ctx, tx, key.Group, key.Resource, nextRV); err != nil { b.log.Error("error saving RV", "error", err, "key", resource.NSGR(key)) } else { b.log.Info("successfully saved RV", "rv", nextRV, "key", resource.NSGR(key)) diff --git a/pkg/storage/unified/sql/list_iterator_test.go b/pkg/storage/unified/sql/list_iterator_test.go index 3b567ca0f3e..e049e06525e 100644 --- a/pkg/storage/unified/sql/list_iterator_test.go +++ b/pkg/storage/unified/sql/list_iterator_test.go @@ -17,6 +17,7 @@ import ( dbsql "github.com/grafana/grafana/pkg/storage/unified/sql/db" "github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl" "github.com/grafana/grafana/pkg/storage/unified/sql/dbutil" + "github.com/grafana/grafana/pkg/storage/unified/sql/rvmanager" "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util/testutil" @@ -94,7 +95,7 @@ func TestIntegrationListIter(t *testing.T) { return fmt.Errorf("failed to insert test data: %w", err) } - if _, err = dbutil.Exec(ctx, tx, sqlResourceUpdateRV, sqlResourceUpdateRVRequest{ + if _, err = dbutil.Exec(ctx, tx, rvmanager.SqlResourceUpdateRV, rvmanager.SqlResourceUpdateRVRequest{ SQLTemplate: sqltemplate.New(dialect), GUIDToRV: map[string]int64{ item.guid: item.resourceVersion, diff --git a/pkg/storage/unified/sql/queries.go b/pkg/storage/unified/sql/queries.go index 5b9a177e17c..725c4617403 100644 --- a/pkg/storage/unified/sql/queries.go +++ b/pkg/storage/unified/sql/queries.go @@ -38,10 +38,8 @@ var ( sqlResourceList = mustTemplate("resource_list.sql") sqlResourceHistoryList = mustTemplate("resource_history_list.sql") sqlResourceHistoryListModifiedSince = mustTemplate("resource_history_list_since_modified.sql") - sqlResourceUpdateRV = mustTemplate("resource_update_rv.sql") sqlResourceHistoryRead = mustTemplate("resource_history_read.sql") sqlResourceHistoryReadLatestRV = mustTemplate("resource_history_read_latest_rv.sql") - sqlResourceHistoryUpdateRV = mustTemplate("resource_history_update_rv.sql") sqlResourceHistoryInsert = mustTemplate("resource_history_insert.sql") sqlResourceHistoryPoll = mustTemplate("resource_history_poll.sql") sqlResourceHistoryGet = mustTemplate("resource_history_get.sql") @@ -51,10 +49,7 @@ var ( sqlResourceInsertFromHistory = mustTemplate("resource_insert_from_history.sql") // sqlResourceLabelsInsert = mustTemplate("resource_labels_insert.sql") - sqlResourceVersionGet = mustTemplate("resource_version_get.sql") - sqlResourceVersionUpdate = mustTemplate("resource_version_update.sql") - sqlResourceVersionInsert = mustTemplate("resource_version_insert.sql") - sqlResourceVersionList = mustTemplate("resource_version_list.sql") + sqlResourceVersionList = mustTemplate("resource_version_list.sql") sqlResourceBlobInsert = mustTemplate("resource_blob_insert.sql") sqlResourceBlobQuery = mustTemplate("resource_blob_query.sql") @@ -365,76 +360,11 @@ func (r sqlResourceBlobQueryRequest) Validate() error { return nil } -// update RV - -type sqlResourceUpdateRVRequest struct { - sqltemplate.SQLTemplate - GUIDToRV map[string]int64 - GUIDToSnowflakeRV map[string]int64 -} - -func (r sqlResourceUpdateRVRequest) Validate() error { - return nil // TODO -} - -func (r sqlResourceUpdateRVRequest) SlashFunc() string { - if r.DialectName() == "postgres" { - return "CHR(47)" - } - - return "CHAR(47)" -} - -func (r sqlResourceUpdateRVRequest) TildeFunc() string { - if r.DialectName() == "postgres" { - return "CHR(126)" - } - - return "CHAR(126)" -} - -// resource_version table requests. -type resourceVersionResponse struct { - ResourceVersion int64 - CurrentEpoch int64 -} - -func (r *resourceVersionResponse) Results() (*resourceVersionResponse, error) { - return r, nil -} - type groupResourceVersion struct { Group, Resource string ResourceVersion int64 } -type sqlResourceVersionUpsertRequest struct { - sqltemplate.SQLTemplate - Group, Resource string - ResourceVersion int64 -} - -func (r sqlResourceVersionUpsertRequest) Validate() error { - return nil // TODO -} - -type sqlResourceVersionGetRequest struct { - sqltemplate.SQLTemplate - Group, Resource string - ReadOnly bool - Response *resourceVersionResponse -} - -func (r sqlResourceVersionGetRequest) Validate() error { - return nil // TODO -} -func (r sqlResourceVersionGetRequest) Results() (*resourceVersionResponse, error) { - return &resourceVersionResponse{ - ResourceVersion: r.Response.ResourceVersion, - CurrentEpoch: r.Response.CurrentEpoch, - }, nil -} - type sqlResourceVersionListRequest struct { sqltemplate.SQLTemplate *groupResourceVersion diff --git a/pkg/storage/unified/sql/queries_test.go b/pkg/storage/unified/sql/queries_test.go index 5673bfad1e8..93e9b1f5af4 100644 --- a/pkg/storage/unified/sql/queries_test.go +++ b/pkg/storage/unified/sql/queries_test.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" + "github.com/grafana/grafana/pkg/storage/unified/sql/rvmanager" "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate/mocks" ) @@ -162,10 +163,10 @@ func TestUnifiedStorageQueries(t *testing.T) { }, }, - sqlResourceUpdateRV: { + rvmanager.SqlResourceUpdateRV: { { Name: "single path", - Data: &sqlResourceUpdateRVRequest{ + Data: &rvmanager.SqlResourceUpdateRVRequest{ SQLTemplate: mocks.NewTestingSQLTemplate(), GUIDToRV: map[string]int64{ "guid1": 123, @@ -228,10 +229,10 @@ func TestUnifiedStorageQueries(t *testing.T) { }, }, - sqlResourceHistoryUpdateRV: { + rvmanager.SqlResourceHistoryUpdateRV: { { Name: "single path", - Data: &sqlResourceUpdateRVRequest{ + Data: &rvmanager.SqlResourceUpdateRVRequest{ SQLTemplate: mocks.NewTestingSQLTemplate(), GUIDToRV: map[string]int64{ "guid1": 123, @@ -334,23 +335,23 @@ func TestUnifiedStorageQueries(t *testing.T) { }, }, - sqlResourceVersionGet: { + rvmanager.SqlResourceVersionGet: { { Name: "single path", - Data: &sqlResourceVersionGetRequest{ + Data: &rvmanager.SqlResourceVersionGetRequest{ SQLTemplate: mocks.NewTestingSQLTemplate(), Resource: "resource", Group: "group", - Response: new(resourceVersionResponse), + Response: new(rvmanager.ResourceVersionResponse), ReadOnly: false, }, }, }, - sqlResourceVersionUpdate: { + rvmanager.SqlResourceVersionUpdate: { { Name: "increment resource version", - Data: &sqlResourceVersionUpsertRequest{ + Data: &rvmanager.SqlResourceVersionUpsertRequest{ SQLTemplate: mocks.NewTestingSQLTemplate(), Resource: "resource", Group: "group", @@ -359,10 +360,10 @@ func TestUnifiedStorageQueries(t *testing.T) { }, }, - sqlResourceVersionInsert: { + rvmanager.SqlResourceVersionInsert: { { Name: "single path", - Data: &sqlResourceVersionUpsertRequest{ + Data: &rvmanager.SqlResourceVersionUpsertRequest{ SQLTemplate: mocks.NewTestingSQLTemplate(), ResourceVersion: int64(12354), }, diff --git a/pkg/storage/unified/sql/data/resource_history_update_rv.sql b/pkg/storage/unified/sql/rvmanager/data/resource_history_update_rv.sql similarity index 100% rename from pkg/storage/unified/sql/data/resource_history_update_rv.sql rename to pkg/storage/unified/sql/rvmanager/data/resource_history_update_rv.sql diff --git a/pkg/storage/unified/sql/data/resource_update_rv.sql b/pkg/storage/unified/sql/rvmanager/data/resource_update_rv.sql similarity index 100% rename from pkg/storage/unified/sql/data/resource_update_rv.sql rename to pkg/storage/unified/sql/rvmanager/data/resource_update_rv.sql diff --git a/pkg/storage/unified/sql/data/resource_version_get.sql b/pkg/storage/unified/sql/rvmanager/data/resource_version_get.sql similarity index 100% rename from pkg/storage/unified/sql/data/resource_version_get.sql rename to pkg/storage/unified/sql/rvmanager/data/resource_version_get.sql diff --git a/pkg/storage/unified/sql/data/resource_version_insert.sql b/pkg/storage/unified/sql/rvmanager/data/resource_version_insert.sql similarity index 100% rename from pkg/storage/unified/sql/data/resource_version_insert.sql rename to pkg/storage/unified/sql/rvmanager/data/resource_version_insert.sql diff --git a/pkg/storage/unified/sql/data/resource_version_update.sql b/pkg/storage/unified/sql/rvmanager/data/resource_version_update.sql similarity index 100% rename from pkg/storage/unified/sql/data/resource_version_update.sql rename to pkg/storage/unified/sql/rvmanager/data/resource_version_update.sql diff --git a/pkg/storage/unified/sql/rvmanager/queries.go b/pkg/storage/unified/sql/rvmanager/queries.go new file mode 100644 index 00000000000..c0411cdf704 --- /dev/null +++ b/pkg/storage/unified/sql/rvmanager/queries.go @@ -0,0 +1,84 @@ +package rvmanager + +import ( + "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" +) + +type SqlResourceUpdateRVRequest struct { + sqltemplate.SQLTemplate + GUIDToRV map[string]int64 + GUIDToSnowflakeRV map[string]int64 +} + +func (r SqlResourceUpdateRVRequest) Validate() error { + return nil // TODO +} + +func (r SqlResourceUpdateRVRequest) SlashFunc() string { + if r.DialectName() == "postgres" { + return "CHR(47)" + } + + return "CHAR(47)" +} + +func (r SqlResourceUpdateRVRequest) TildeFunc() string { + if r.DialectName() == "postgres" { + return "CHR(126)" + } + + return "CHAR(126)" +} + +type ResourceVersionResponse struct { + ResourceVersion int64 + CurrentEpoch int64 +} + +func (r *ResourceVersionResponse) Results() (*ResourceVersionResponse, error) { + return r, nil +} + +type sqlResourceVersionGetRequest struct { + sqltemplate.SQLTemplate + Group, Resource string + ReadOnly bool + Response *ResourceVersionResponse +} + +func (r sqlResourceVersionGetRequest) Validate() error { + return nil // TODO +} +func (r sqlResourceVersionGetRequest) Results() (*ResourceVersionResponse, error) { + return &ResourceVersionResponse{ + ResourceVersion: r.Response.ResourceVersion, + CurrentEpoch: r.Response.CurrentEpoch, + }, nil +} + +type SqlResourceVersionUpsertRequest struct { + sqltemplate.SQLTemplate + Group, Resource string + ResourceVersion int64 +} + +func (r SqlResourceVersionUpsertRequest) Validate() error { + return nil // TODO +} + +type SqlResourceVersionGetRequest struct { + sqltemplate.SQLTemplate + Group, Resource string + ReadOnly bool + Response *ResourceVersionResponse +} + +func (r SqlResourceVersionGetRequest) Validate() error { + return nil // TODO +} +func (r SqlResourceVersionGetRequest) Results() (*ResourceVersionResponse, error) { + return &ResourceVersionResponse{ + ResourceVersion: r.Response.ResourceVersion, + CurrentEpoch: r.Response.CurrentEpoch, + }, nil +} diff --git a/pkg/storage/unified/sql/rv_manager.go b/pkg/storage/unified/sql/rvmanager/rv_manager.go similarity index 89% rename from pkg/storage/unified/sql/rv_manager.go rename to pkg/storage/unified/sql/rvmanager/rv_manager.go index 858345b1fc2..b4f3b5de596 100644 --- a/pkg/storage/unified/sql/rv_manager.go +++ b/pkg/storage/unified/sql/rvmanager/rv_manager.go @@ -1,4 +1,4 @@ -package sql +package rvmanager import ( "context" @@ -11,6 +11,7 @@ import ( "github.com/bwmarrin/snowflake" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" + "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" @@ -20,6 +21,8 @@ import ( "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" ) +var tracer = otel.Tracer("github.com/grafana/grafana/pkg/storage/unified/sql/rvmanager") + var ( rvmWriteDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{ Name: "rvmanager_write_duration_seconds", @@ -62,8 +65,8 @@ const ( defaultBatchTimeout = 5 * time.Second ) -// resourceVersionManager handles resource version operations -type resourceVersionManager struct { +// ResourceVersionManager handles resource version operations +type ResourceVersionManager struct { dialect sqltemplate.Dialect db db.DB batchMu sync.RWMutex @@ -100,7 +103,7 @@ type ResourceManagerOptions struct { } // NewResourceVersionManager creates a new ResourceVersionManager -func NewResourceVersionManager(opts ResourceManagerOptions) (*resourceVersionManager, error) { +func NewResourceVersionManager(opts ResourceManagerOptions) (*ResourceVersionManager, error) { if opts.MaxBatchSize == 0 { opts.MaxBatchSize = defaultMaxBatchSize } @@ -113,7 +116,7 @@ func NewResourceVersionManager(opts ResourceManagerOptions) (*resourceVersionMan if opts.DB == nil { return nil, errors.New("db is required") } - return &resourceVersionManager{ + return &ResourceVersionManager{ dialect: opts.Dialect, db: opts.DB, batchChMap: make(map[string]chan *writeOp), @@ -123,7 +126,7 @@ func NewResourceVersionManager(opts ResourceManagerOptions) (*resourceVersionMan } // ExecWithRV executes the given function with an incremented resource version -func (m *resourceVersionManager) ExecWithRV(ctx context.Context, key *resourcepb.ResourceKey, fn WriteEventFunc) (rv int64, err error) { +func (m *ResourceVersionManager) ExecWithRV(ctx context.Context, key *resourcepb.ResourceKey, fn WriteEventFunc) (rv int64, err error) { rvmInflightWrites.WithLabelValues(key.Group, key.Resource).Inc() defer rvmInflightWrites.WithLabelValues(key.Group, key.Resource).Dec() @@ -179,7 +182,7 @@ func (m *resourceVersionManager) ExecWithRV(ctx context.Context, key *resourcepb } // startBatchProcessor is responsible for processing batches of write operations -func (m *resourceVersionManager) startBatchProcessor(group, resource string) { +func (m *ResourceVersionManager) startBatchProcessor(group, resource string) { ctx := context.TODO() batchKey := fmt.Sprintf("%s/%s", group, resource) @@ -216,7 +219,11 @@ func (m *resourceVersionManager) startBatchProcessor(group, resource string) { } } -func (m *resourceVersionManager) execBatch(ctx context.Context, group, resource string, batch []writeOp) { +var readCommitted = &sql.TxOptions{ + Isolation: sql.LevelReadCommitted, +} + +func (m *ResourceVersionManager) execBatch(ctx context.Context, group, resource string, batch []writeOp) { ctx, span := tracer.Start(ctx, "sql.resourceVersionManager.execBatch") defer span.End() @@ -245,7 +252,7 @@ func (m *resourceVersionManager) execBatch(ctx context.Context, group, resource guids := make([]string, len(batch)) // The GUIDs of the created resources in the same order as the batch rvs := make([]int64, len(batch)) // The RVs of the created resources in the same order as the batch - err = m.db.WithTx(ctx, ReadCommitted, func(ctx context.Context, tx db.Tx) error { + err = m.db.WithTx(ctx, readCommitted, func(ctx context.Context, tx db.Tx) error { span.AddEvent("starting_batch_transaction") writeTimer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) { @@ -268,7 +275,7 @@ func (m *resourceVersionManager) execBatch(ctx context.Context, group, resource lockTimer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) { rvmExecBatchPhaseDuration.WithLabelValues(group, resource, "waiting_for_lock").Observe(v) })) - rv, err := m.lock(ctx, tx, group, resource) + rv, err := m.Lock(ctx, tx, group, resource) lockTimer.ObserveDuration() if err != nil { span.AddEvent("resource_version_lock_failed", trace.WithAttributes( @@ -292,7 +299,7 @@ func (m *resourceVersionManager) execBatch(ctx context.Context, group, resource rv++ } // Update the resource version for the created resources in both the resource and the resource history - if _, err := dbutil.Exec(ctx, tx, sqlResourceUpdateRV, sqlResourceUpdateRVRequest{ + if _, err := dbutil.Exec(ctx, tx, SqlResourceUpdateRV, SqlResourceUpdateRVRequest{ SQLTemplate: sqltemplate.New(m.dialect), GUIDToRV: guidToRV, }); err != nil { @@ -303,7 +310,7 @@ func (m *resourceVersionManager) execBatch(ctx context.Context, group, resource } span.AddEvent("resource_versions_updated") - if _, err := dbutil.Exec(ctx, tx, sqlResourceHistoryUpdateRV, sqlResourceUpdateRVRequest{ + if _, err := dbutil.Exec(ctx, tx, SqlResourceHistoryUpdateRV, SqlResourceUpdateRVRequest{ SQLTemplate: sqltemplate.New(m.dialect), GUIDToRV: guidToRV, GUIDToSnowflakeRV: guidToSnowflakeRV, @@ -316,7 +323,7 @@ func (m *resourceVersionManager) execBatch(ctx context.Context, group, resource span.AddEvent("resource_history_versions_updated") // Record the latest RV in the resource version table - err = m.saveRV(ctx, tx, group, resource, rv) + err = m.SaveRV(ctx, tx, group, resource, rv) if err != nil { span.AddEvent("save_rv_failed", trace.WithAttributes( attribute.String("error", err.Error()), @@ -350,20 +357,20 @@ func snowflakeFromRv(rv int64) int64 { return (((rv / 1000) - snowflake.Epoch) << (snowflake.NodeBits + snowflake.StepBits)) + (rv % 1000) } -// lock locks the resource version for the given key -func (m *resourceVersionManager) lock(ctx context.Context, x db.ContextExecer, group, resource string) (nextRV int64, err error) { +// Lock locks the resource version for the given key +func (m *ResourceVersionManager) Lock(ctx context.Context, x db.ContextExecer, group, resource string) (nextRV int64, err error) { // 1. Lock the row and prevent concurrent updates until the transaction is committed - res, err := dbutil.QueryRow(ctx, x, sqlResourceVersionGet, sqlResourceVersionGetRequest{ + res, err := dbutil.QueryRow(ctx, x, SqlResourceVersionGet, sqlResourceVersionGetRequest{ SQLTemplate: sqltemplate.New(m.dialect), Group: group, Resource: resource, - Response: new(resourceVersionResponse), + Response: new(ResourceVersionResponse), ReadOnly: false, // Lock the row for update }) if errors.Is(err, sql.ErrNoRows) { // If there wasn't a row for this resource, create it - if _, err = dbutil.Exec(ctx, x, sqlResourceVersionInsert, sqlResourceVersionUpsertRequest{ + if _, err = dbutil.Exec(ctx, x, SqlResourceVersionInsert, SqlResourceVersionUpsertRequest{ SQLTemplate: sqltemplate.New(m.dialect), Group: group, Resource: resource, @@ -372,11 +379,11 @@ func (m *resourceVersionManager) lock(ctx context.Context, x db.ContextExecer, g } // Fetch the newly created resource version - res, err = dbutil.QueryRow(ctx, x, sqlResourceVersionGet, sqlResourceVersionGetRequest{ + res, err = dbutil.QueryRow(ctx, x, SqlResourceVersionGet, sqlResourceVersionGetRequest{ SQLTemplate: sqltemplate.New(m.dialect), Group: group, Resource: resource, - Response: new(resourceVersionResponse), + Response: new(ResourceVersionResponse), ReadOnly: true, }) if err != nil { @@ -390,8 +397,8 @@ func (m *resourceVersionManager) lock(ctx context.Context, x db.ContextExecer, g return max(res.CurrentEpoch, res.ResourceVersion+1), nil } -func (m *resourceVersionManager) saveRV(ctx context.Context, x db.ContextExecer, group, resource string, rv int64) error { - _, err := dbutil.Exec(ctx, x, sqlResourceVersionUpdate, sqlResourceVersionUpsertRequest{ +func (m *ResourceVersionManager) SaveRV(ctx context.Context, x db.ContextExecer, group, resource string, rv int64) error { + _, err := dbutil.Exec(ctx, x, SqlResourceVersionUpdate, SqlResourceVersionUpsertRequest{ SQLTemplate: sqltemplate.New(m.dialect), Group: group, Resource: resource, diff --git a/pkg/storage/unified/sql/rv_manager_test.go b/pkg/storage/unified/sql/rvmanager/rv_manager_test.go similarity index 99% rename from pkg/storage/unified/sql/rv_manager_test.go rename to pkg/storage/unified/sql/rvmanager/rv_manager_test.go index 46d23d26a59..9a2e105aa2f 100644 --- a/pkg/storage/unified/sql/rv_manager_test.go +++ b/pkg/storage/unified/sql/rvmanager/rv_manager_test.go @@ -1,4 +1,4 @@ -package sql +package rvmanager import ( "testing" diff --git a/pkg/storage/unified/sql/rvmanager/templates.go b/pkg/storage/unified/sql/rvmanager/templates.go new file mode 100644 index 00000000000..65c91a273af --- /dev/null +++ b/pkg/storage/unified/sql/rvmanager/templates.go @@ -0,0 +1,30 @@ +package rvmanager + +import ( + "embed" + "fmt" + "text/template" +) + +// Templates setup. +var ( + //go:embed data/*.sql + sqlTemplatesFS embed.FS + + sqlTemplates = template.Must(template.New("sql").ParseFS(sqlTemplatesFS, `data/*.sql`)) +) + +func mustTemplate(filename string) *template.Template { + if t := sqlTemplates.Lookup(filename); t != nil { + return t + } + panic(fmt.Sprintf("template file not found: %s", filename)) +} + +var ( + SqlResourceUpdateRV = mustTemplate("resource_update_rv.sql") + SqlResourceHistoryUpdateRV = mustTemplate("resource_history_update_rv.sql") + SqlResourceVersionGet = mustTemplate("resource_version_get.sql") + SqlResourceVersionUpdate = mustTemplate("resource_version_update.sql") + SqlResourceVersionInsert = mustTemplate("resource_version_insert.sql") +) From 7360194ab9ff58235c0817a6fc7d7ffa4fd97c09 Mon Sep 17 00:00:00 2001 From: Tania <10127682+undef1nd@users.noreply.github.com> Date: Fri, 19 Dec 2025 09:55:47 +0100 Subject: [PATCH 040/229] Chore: Remove `unifiedReqeustLog` feature flag (#115559) Chore: Remove unifiedReqeustLog feature flag --- .../configure-grafana/feature-toggles/index.md | 1 - packages/grafana-data/src/types/featureToggles.gen.ts | 5 ----- pkg/middleware/loggermw/logger.go | 5 +---- pkg/services/featuremgmt/registry.go | 7 ------- pkg/services/featuremgmt/toggles_gen.csv | 1 - pkg/services/featuremgmt/toggles_gen.go | 4 ---- pkg/services/featuremgmt/toggles_gen.json | 3 ++- 7 files changed, 3 insertions(+), 23 deletions(-) diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index b80fa49f815..16c97f6d10a 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -31,7 +31,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `logsContextDatasourceUi` | Allow datasource to provide custom UI for context view | Yes | | `lokiQuerySplitting` | Split large interval queries into subqueries with smaller time intervals | Yes | | `influxdbBackendMigration` | Query InfluxDB InfluxQL without the proxy | Yes | -| `unifiedRequestLog` | Writes error logs to the request logger | Yes | | `logsExploreTableVisualisation` | A table visualisation for logs in Explore | Yes | | `awsDatasourcesTempCredentials` | Support temporary security credentials in AWS plugins for Grafana Cloud customers | Yes | | `awsAsyncQueryCaching` | Enable caching for async queries for Redshift and Athena. Requires that the datasource has caching and async query support enabled | Yes | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 199be6d9c3f..ea9f1c22f39 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -126,11 +126,6 @@ export interface FeatureToggles { */ disableSSEDataplane?: boolean; /** - * Writes error logs to the request logger - * @default true - */ - unifiedRequestLog?: boolean; - /** * Uses JWT-based auth for rendering instead of relying on remote cache */ renderAuthJWT?: boolean; diff --git a/pkg/middleware/loggermw/logger.go b/pkg/middleware/loggermw/logger.go index 1255d87aa0e..1b4f3d4d2d4 100644 --- a/pkg/middleware/loggermw/logger.go +++ b/pkg/middleware/loggermw/logger.go @@ -64,10 +64,7 @@ func (l *loggerImpl) Middleware() web.Middleware { // put the start time on context so we can measure it later. r = r.WithContext(log.InitstartTime(r.Context(), time.Now())) - //nolint:staticcheck // not yet migrated to OpenFeature - if l.flags.IsEnabled(r.Context(), featuremgmt.FlagUnifiedRequestLog) { - r = r.WithContext(errutil.SetUnifiedLogging(r.Context())) - } + r = r.WithContext(errutil.SetUnifiedLogging(r.Context())) rw := web.Rw(w, r) next.ServeHTTP(rw, r) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index e9a4a8c7caf..c5aaf9fbf63 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -185,13 +185,6 @@ var ( Stage: FeatureStageExperimental, Owner: grafanaDatasourcesCoreServicesSquad, }, - { - Name: "unifiedRequestLog", - Description: "Writes error logs to the request logger", - Stage: FeatureStageGeneralAvailability, - Owner: grafanaBackendGroup, - Expression: "true", - }, { Name: "renderAuthJWT", Description: "Uses JWT-based auth for rendering instead of relying on remote cache", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index d56aff17bb7..6889cb9c040 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -24,7 +24,6 @@ influxqlStreamingParser,experimental,@grafana/partner-datasources,false,false,fa influxdbRunQueriesInParallel,privatePreview,@grafana/partner-datasources,false,false,false lokiLogsDataplane,experimental,@grafana/observability-logs,false,false,false disableSSEDataplane,experimental,@grafana/grafana-datasources-core-services,false,false,false -unifiedRequestLog,GA,@grafana/grafana-backend-group,false,false,false renderAuthJWT,preview,@grafana/grafana-operator-experience-squad,false,false,false refactorVariablesTimeRange,preview,@grafana/dashboards-squad,false,false,false faroDatasourceSelector,preview,@grafana/app-o11y,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index fd77326e8b0..2797b046d57 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -79,10 +79,6 @@ const ( // Disables dataplane specific processing in server side expressions. FlagDisableSSEDataplane = "disableSSEDataplane" - // FlagUnifiedRequestLog - // Writes error logs to the request logger - FlagUnifiedRequestLog = "unifiedRequestLog" - // FlagRenderAuthJWT // Uses JWT-based auth for rendering instead of relying on remote cache FlagRenderAuthJWT = "renderAuthJWT" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index a0afcd87626..63257b6d738 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -3515,7 +3515,8 @@ "metadata": { "name": "unifiedRequestLog", "resourceVersion": "1764664939750", - "creationTimestamp": "2023-03-31T13:38:09Z" + "creationTimestamp": "2023-03-31T13:38:09Z", + "deletionTimestamp": "2025-12-18T14:21:02Z" }, "spec": { "description": "Writes error logs to the request logger", From 285f2b1d3257d8ce3a74a0cc4c40b4fd4ab0958e Mon Sep 17 00:00:00 2001 From: Misi Date: Fri, 19 Dec 2025 10:28:20 +0100 Subject: [PATCH 041/229] Auth: Allow service accounts to authenticate to ST Grafana (#115536) * Allow SAs to authn ext_jwt * Address feedback --- pkg/services/authn/clients/ext_jwt.go | 3 +- pkg/services/authn/clients/ext_jwt_test.go | 36 +++++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/pkg/services/authn/clients/ext_jwt.go b/pkg/services/authn/clients/ext_jwt.go index b479f314c76..be9efa9ce76 100644 --- a/pkg/services/authn/clients/ext_jwt.go +++ b/pkg/services/authn/clients/ext_jwt.go @@ -131,7 +131,8 @@ func (s *ExtendedJWT) authenticateAsUser( return nil, errExtJWTInvalid.Errorf("failed to parse id token subject: %w", err) } - if !claims.IsIdentityType(t, claims.TypeUser) { + // TODO: How to support other identity types like render and anonymous here? + if !claims.IsIdentityType(t, claims.TypeUser, claims.TypeServiceAccount) { return nil, errExtJWTInvalidSubject.Errorf("unexpected identity: %s", idTokenClaims.Subject) } diff --git a/pkg/services/authn/clients/ext_jwt_test.go b/pkg/services/authn/clients/ext_jwt_test.go index 88cf11fb83a..3c7203844b1 100644 --- a/pkg/services/authn/clients/ext_jwt_test.go +++ b/pkg/services/authn/clients/ext_jwt_test.go @@ -53,6 +53,17 @@ var ( Namespace: "default", // org ID of 1 is special and translates to default }, } + validIDTokenClaimsWithServiceAccount = idTokenClaims{ + Claims: jwt.Claims{ + Subject: "service-account:3", + Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), + IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), + }, + Rest: authnlib.IDTokenClaims{ + AuthenticatedBy: "extended_jwt", + Namespace: "default", // org ID of 1 is special and translates to default + }, + } validIDTokenClaimsWithStackSet = idTokenClaims{ Claims: jwt.Claims{ Subject: "user:2", @@ -118,7 +129,7 @@ var ( } invalidSubjectIDTokenClaims = idTokenClaims{ Claims: jwt.Claims{ - Subject: "service-account:2", + Subject: "anonymous:2", Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), }, @@ -286,6 +297,29 @@ func TestExtendedJWT_Authenticate(t *testing.T) { }, }, }, + { + name: "should authenticate as service account", + accessToken: &validAccessTokenClaims, + idToken: &validIDTokenClaimsWithServiceAccount, + orgID: 1, + want: &authn.Identity{ + ID: "3", + Type: claims.TypeServiceAccount, + OrgID: 1, + AccessTokenClaims: &validAccessTokenClaims, + IDTokenClaims: &validIDTokenClaimsWithServiceAccount, + Namespace: "default", + AuthenticatedBy: "extendedjwt", + AuthID: "access-policy:this-uid", + ClientParams: authn.ClientParams{ + FetchSyncedUser: true, + SyncPermissions: true, + FetchPermissionsParams: authn.FetchPermissionsParams{ + RestrictedActions: []string{"dashboards:create", "folders:read", "datasources:explore", "datasources.insights:read"}, + }, + }, + }, + }, { name: "should authenticate as user in the user namespace", accessToken: &validAccessTokenClaimsWildcard, From b5793a5f73a9be355f3a39947867783c0d8d64a2 Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Fri, 19 Dec 2025 11:43:46 +0100 Subject: [PATCH 042/229] Alerting: Fix receiver_name and has_prometheus_definition filters with compact=true (#115582) --- .../ngalert/api/api_prometheus_test.go | 51 ++++++++++++ pkg/services/ngalert/store/alert_rule.go | 24 ++++-- pkg/services/ngalert/store/compat.go | 25 +++--- pkg/services/ngalert/store/compat_test.go | 82 ++++++++++++++++++- 4 files changed, 163 insertions(+), 19 deletions(-) diff --git a/pkg/services/ngalert/api/api_prometheus_test.go b/pkg/services/ngalert/api/api_prometheus_test.go index 71e25d4c963..75ec6c901fd 100644 --- a/pkg/services/ngalert/api/api_prometheus_test.go +++ b/pkg/services/ngalert/api/api_prometheus_test.go @@ -2169,6 +2169,57 @@ func TestRouteGetRuleStatuses(t *testing.T) { }) }) + t.Run("compact mode with receiver_name filter returns only matching rules", func(t *testing.T) { + fakeStore, _, api := setupAPI(t) + + ruleA := gen.With( + gen.WithGroupKey(ngmodels.AlertRuleGroupKey{ + NamespaceUID: "folder-1", + RuleGroup: "group-1", + OrgID: orgID, + }), + gen.WithNotificationSettings( + ngmodels.NotificationSettings{ + Receiver: "receiver-a", + GroupBy: []string{"alertname"}, + }, + ), + ).GenerateRef() + fakeStore.PutRule(context.Background(), ruleA) + + ruleB := gen.With( + gen.WithGroupKey(ngmodels.AlertRuleGroupKey{ + NamespaceUID: "folder-2", + RuleGroup: "group-2", + OrgID: orgID, + }), + gen.WithNotificationSettings( + ngmodels.NotificationSettings{ + Receiver: "receiver-b", + GroupBy: []string{"alertname"}, + }, + ), + ).GenerateRef() + fakeStore.PutRule(context.Background(), ruleB) + r, err := http.NewRequest("GET", "/api/v1/rules?compact=true&receiver_name=receiver-a", nil) + require.NoError(t, err) + c := &contextmodel.ReqContext{ + Context: &web.Context{Req: r}, + SignedInUser: &user.SignedInUser{ + OrgID: orgID, + Permissions: queryPermissions, + }, + } + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + var res apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res)) + + require.Len(t, res.Data.RuleGroups, 1) + require.Equal(t, "group-1", res.Data.RuleGroups[0].Name) + require.Empty(t, res.Data.RuleGroups[0].Rules[0].Query, "Query should be empty in compact mode") + }) + t.Run("provenance as expected", func(t *testing.T) { fakeStore, fakeAIM, api, provStore := setupAPIFull(t) // Rule without provenance diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index 5957e7026b2..30856587794 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -642,6 +642,23 @@ func (st DBstore) ListAlertRulesByGroup(ctx context.Context, query *ngmodels.Lis _ = rows.Close() }() + opts := AlertRuleConvertOptions{} + if query.Compact { + opts.ExcludeAlertQueries = true + opts.ExcludeNotificationSettings = true + opts.ExcludeMetadata = true + + if query.ReceiverName != "" || query.TimeIntervalName != "" { + // Need NotificationSettings for these filters + opts.ExcludeNotificationSettings = false + } + + if query.HasPrometheusRuleDefinition != nil { + // Need Metadata for this filter + opts.ExcludeMetadata = false + } + } + // Process rules and implement per-group pagination var groupsFetched int64 var rulesFetched int64 @@ -653,12 +670,7 @@ func (st DBstore) ListAlertRulesByGroup(ctx context.Context, query *ngmodels.Lis continue } - var converted ngmodels.AlertRule - if query.Compact { - converted, err = alertRuleToModelsAlertRuleCompact(*rule, st.Logger) - } else { - converted, err = alertRuleToModelsAlertRule(*rule, st.Logger) - } + converted, err := convertAlertRuleToModel(*rule, st.Logger, opts) if err != nil { st.Logger.Error("Invalid rule found in DB store, cannot convert, ignoring it", "func", "ListAlertRulesByGroup", "error", err) diff --git a/pkg/services/ngalert/store/compat.go b/pkg/services/ngalert/store/compat.go index fbb69addc72..57dcf411853 100644 --- a/pkg/services/ngalert/store/compat.go +++ b/pkg/services/ngalert/store/compat.go @@ -15,22 +15,23 @@ type compactQuery struct { DatasourceUID string `json:"datasourceUid"` } -func alertRuleToModelsAlertRule(ar alertRule, l log.Logger) (models.AlertRule, error) { - return convertAlertRuleToModel(ar, l, false) +// AlertRuleConvertOptions controls which fields to parse during conversion from alertRule to models.AlertRule. +// By default all fields are included. Set Exclude* to true to skip parsing expensive fields. +type AlertRuleConvertOptions struct { + ExcludeAlertQueries bool // Only parse datasource UIDs from queries + ExcludeNotificationSettings bool + ExcludeMetadata bool } -// alertRuleToModelsAlertRuleCompact transforms an alertRule to a models.AlertRule -// ignoring alert queries (except for data source UIDs), notification settings, and metadata. -func alertRuleToModelsAlertRuleCompact(ar alertRule, l log.Logger) (models.AlertRule, error) { - return convertAlertRuleToModel(ar, l, true) +func alertRuleToModelsAlertRule(ar alertRule, l log.Logger) (models.AlertRule, error) { + return convertAlertRuleToModel(ar, l, AlertRuleConvertOptions{}) } // convertAlertRuleToModel creates a models.AlertRule from an alertRule. -// When 'compact' is set to 'true', it skips parsing the alert queries (except for the data source UID), notification -// settings, and metadata, thus reducing the number of JSON serializations needed. -func convertAlertRuleToModel(ar alertRule, l log.Logger, compact bool) (models.AlertRule, error) { +// opts.Exclude* fields control which expensive fields to skip parsing, reducing JSON serializations. +func convertAlertRuleToModel(ar alertRule, l log.Logger, opts AlertRuleConvertOptions) (models.AlertRule, error) { var data []models.AlertQuery - if compact { + if opts.ExcludeAlertQueries { var cqs []compactQuery if err := json.Unmarshal([]byte(ar.Data), &cqs); err != nil { return models.AlertRule{}, fmt.Errorf("failed to parse data: %w", err) @@ -118,7 +119,7 @@ func convertAlertRuleToModel(ar alertRule, l log.Logger, compact bool) (models.A } } - if !compact && ar.NotificationSettings != "" { + if !opts.ExcludeNotificationSettings && ar.NotificationSettings != "" { ns, err := parseNotificationSettings(ar.NotificationSettings) if err != nil { return models.AlertRule{}, fmt.Errorf("failed to parse notification settings: %w", err) @@ -126,7 +127,7 @@ func convertAlertRuleToModel(ar alertRule, l log.Logger, compact bool) (models.A result.NotificationSettings = ns } - if !compact && ar.Metadata != "" { + if !opts.ExcludeMetadata && ar.Metadata != "" { err = json.Unmarshal([]byte(ar.Metadata), &result.Metadata) if err != nil { return models.AlertRule{}, fmt.Errorf("failed to metadata: %w", err) diff --git a/pkg/services/ngalert/store/compat_test.go b/pkg/services/ngalert/store/compat_test.go index ef80f51d668..d4aab40931f 100644 --- a/pkg/services/ngalert/store/compat_test.go +++ b/pkg/services/ngalert/store/compat_test.go @@ -84,7 +84,11 @@ func TestAlertRuleToModelsAlertRuleCompact(t *testing.T) { Metadata: `{"editor_settings":{"simplified_query_and_expressions_section":true}}`, } - compactResult, err := alertRuleToModelsAlertRuleCompact(rule, &logtest.Fake{}) + compactResult, err := convertAlertRuleToModel(rule, &logtest.Fake{}, AlertRuleConvertOptions{ + ExcludeAlertQueries: true, + ExcludeNotificationSettings: true, + ExcludeMetadata: true, + }) require.NoError(t, err) // Should have datasource UIDs. @@ -142,6 +146,82 @@ func TestAlertRuleToModelsAlertRuleCompact(t *testing.T) { // Should have metadata (metadata is parsed from JSON to struct). require.NotEqual(t, ngmodels.AlertRuleMetadata{}, fullResult.Metadata) }) + + t.Run("compact mode with notification settings included for filtering", func(t *testing.T) { + rule := alertRule{ + ID: 1, + OrgID: 1, + UID: "test-uid", + Title: "Test Rule", + Condition: "A", + Data: `[{"datasourceUid":"ds1","refId":"A","queryType":"test","model":{"expr":"up"}}]`, + IntervalSeconds: 60, + Version: 1, + NamespaceUID: "ns-uid", + RuleGroup: "test-group", + NoDataState: "NoData", + ExecErrState: "Error", + NotificationSettings: `[{"receiver":"test-receiver"}]`, + Metadata: `{"editor_settings":{"simplified_query_and_expressions_section":true}}`, + } + + result, err := convertAlertRuleToModel(rule, &logtest.Fake{}, AlertRuleConvertOptions{ + ExcludeAlertQueries: true, + ExcludeNotificationSettings: false, + ExcludeMetadata: true, + }) + require.NoError(t, err) + + // Should have compact query data (only datasource UIDs). + require.Len(t, result.Data, 1) + require.Equal(t, "ds1", result.Data[0].DatasourceUID) + require.Empty(t, result.Data[0].RefID) + + // Should have notification settings for filtering. + require.Len(t, result.NotificationSettings, 1) + require.Equal(t, "test-receiver", result.NotificationSettings[0].Receiver) + + // Should not have metadata. + require.Equal(t, ngmodels.AlertRuleMetadata{}, result.Metadata) + }) + + t.Run("compact mode with metadata included for filtering", func(t *testing.T) { + rule := alertRule{ + ID: 1, + OrgID: 1, + UID: "test-uid", + Title: "Test Rule", + Condition: "A", + Data: `[{"datasourceUid":"ds1","refId":"A","queryType":"test","model":{"expr":"up"}}]`, + IntervalSeconds: 60, + Version: 1, + NamespaceUID: "ns-uid", + RuleGroup: "test-group", + NoDataState: "NoData", + ExecErrState: "Error", + NotificationSettings: `[{"receiver":"test-receiver"}]`, + Metadata: `{"prometheus_style_rule":{"original_rule_definition":"alert: TestAlert\n expr: rate(metric[5m]) > 1"}}`, + } + + result, err := convertAlertRuleToModel(rule, &logtest.Fake{}, AlertRuleConvertOptions{ + ExcludeAlertQueries: true, + ExcludeNotificationSettings: true, + ExcludeMetadata: false, + }) + require.NoError(t, err) + + // Should have compact query data (only datasource UIDs). + require.Len(t, result.Data, 1) + require.Equal(t, "ds1", result.Data[0].DatasourceUID) + require.Empty(t, result.Data[0].RefID) + + // Should not have notification settings. + require.Empty(t, result.NotificationSettings) + + // Should have metadata for filtering. + require.NotEqual(t, ngmodels.AlertRuleMetadata{}, result.Metadata) + require.True(t, result.HasPrometheusRuleDefinition()) + }) } func TestAlertRuleVersionToAlertRule(t *testing.T) { From a0751b6e71d5d6a1165fb7222d0858d147f3e423 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Jim=C3=A9nez=20S=C3=A1nchez?= Date: Fri, 19 Dec 2025 12:44:15 +0100 Subject: [PATCH 043/229] Provisioning: Default to folder sync only and block new instance sync repositories (#115569) * Default to folder sync only and block new instance sync repositories - Change default allowed_targets to folder-only in backend configuration - Modify validation to only enforce allowedTargets on CREATE operations - Add deprecation warning for existing instance sync repositories - Update frontend defaults and tests to reflect new behavior Fixes #619 * Update warning message: change 'deprecated' to 'not fully supported' * Fix health check: don't validate allowedTargets for existing repositories Health checks for existing repositories should treat them as UPDATE operations, not CREATE operations, so they don't fail validation for instance sync target. * Fix tests and update i18n translations - Update BootstrapStep tests to reflect folder-only default behavior - Run i18n-extract to update translation file structure * Fix integration tests * Fix tests * Fix provisioning test wizard * Fix fronted test --- apps/provisioning/pkg/repository/tester.go | 8 +- apps/provisioning/pkg/repository/validator.go | 6 +- .../pkg/repository/validator_test.go | 3 +- conf/defaults.ini | 2 +- pkg/registry/apis/provisioning/register.go | 3 +- pkg/setting/setting.go | 2 +- pkg/tests/apis/provisioning/exportjob_test.go | 2 + pkg/tests/apis/provisioning/helper_test.go | 3 + .../apis/provisioning/job_validation_test.go | 4 +- pkg/tests/apis/provisioning/movejob_test.go | 6 +- .../apis/provisioning/repository_test.go | 12 ++- pkg/tests/apis/provisioning/stats_test.go | 7 +- pkg/tests/testinfra/testinfra.go | 7 ++ .../provisioning/Config/ConfigForm.tsx | 5 +- .../features/provisioning/Config/defaults.ts | 2 +- .../provisioning/Shared/RepositoryList.tsx | 12 +++ .../Wizard/BootstrapStep.test.tsx | 73 +++++++++++++------ .../Wizard/ProvisioningWizard.test.tsx | 15 +++- .../Wizard/hooks/useModeOptions.ts | 2 +- public/locales/en-US/grafana.json | 4 + 20 files changed, 128 insertions(+), 50 deletions(-) diff --git a/apps/provisioning/pkg/repository/tester.go b/apps/provisioning/pkg/repository/tester.go index 6bd65443d92..14290d0514b 100644 --- a/apps/provisioning/pkg/repository/tester.go +++ b/apps/provisioning/pkg/repository/tester.go @@ -23,7 +23,13 @@ func NewSimpleRepositoryTester(validator RepositoryValidator) SimpleRepositoryTe // TestRepository validates the repository and then runs a health check func (t *SimpleRepositoryTester) TestRepository(ctx context.Context, repo Repository) (*provisioning.TestResults, error) { - errors := t.validator.ValidateRepository(repo) + // Determine if this is a CREATE or UPDATE operation + // If the repository has been observed by the controller (ObservedGeneration > 0), + // it's an existing repository and we should treat it as UPDATE + cfg := repo.Config() + isCreate := cfg.Status.ObservedGeneration == 0 + + errors := t.validator.ValidateRepository(repo, isCreate) if len(errors) > 0 { rsp := &provisioning.TestResults{ Code: http.StatusUnprocessableEntity, // Invalid diff --git a/apps/provisioning/pkg/repository/validator.go b/apps/provisioning/pkg/repository/validator.go index 73198f0d7ee..46ba81f8cbd 100644 --- a/apps/provisioning/pkg/repository/validator.go +++ b/apps/provisioning/pkg/repository/validator.go @@ -32,7 +32,9 @@ func NewValidator(minSyncInterval time.Duration, allowedTargets []provisioning.S } // ValidateRepository solely does configuration checks on the repository object. It does not run a health check or compare against existing repositories. -func (v *RepositoryValidator) ValidateRepository(repo Repository) field.ErrorList { +// isCreate indicates whether this is a CREATE operation (true) or UPDATE operation (false). +// When isCreate is false, allowedTargets validation is skipped to allow existing repositories to continue working. +func (v *RepositoryValidator) ValidateRepository(repo Repository, isCreate bool) field.ErrorList { list := repo.Validate() cfg := repo.Config() @@ -44,7 +46,7 @@ func (v *RepositoryValidator) ValidateRepository(repo Repository) field.ErrorLis if cfg.Spec.Sync.Target == "" { list = append(list, field.Required(field.NewPath("spec", "sync", "target"), "The target type is required when sync is enabled")) - } else if !slices.Contains(v.allowedTargets, cfg.Spec.Sync.Target) { + } else if isCreate && !slices.Contains(v.allowedTargets, cfg.Spec.Sync.Target) { list = append(list, field.Invalid( field.NewPath("spec", "target"), diff --git a/apps/provisioning/pkg/repository/validator_test.go b/apps/provisioning/pkg/repository/validator_test.go index cb8b726a9bf..75980733e17 100644 --- a/apps/provisioning/pkg/repository/validator_test.go +++ b/apps/provisioning/pkg/repository/validator_test.go @@ -303,7 +303,8 @@ func TestValidateRepository(t *testing.T) { validator := NewValidator(10*time.Second, []provisioning.SyncTargetType{provisioning.SyncTargetTypeFolder}, false) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - errors := validator.ValidateRepository(tt.repository) + // Tests validate new configurations, so always pass isCreate=true + errors := validator.ValidateRepository(tt.repository, true) require.Len(t, errors, tt.expectedErrs) if tt.validateError != nil { tt.validateError(t, errors) diff --git a/conf/defaults.ini b/conf/defaults.ini index 8ee03e6a34c..28cd0420eb2 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -2264,7 +2264,7 @@ fail_tests_on_console = true # List of targets that can be controlled by a repository, separated by |. # Instance means the whole grafana instance will be controlled by a repository. # Folder limits it to a folder within the grafana instance. -allowed_targets = instance|folder +allowed_targets = folder # Whether image rendering is allowed for dashboard previews. # Requires image rendering service to be configured. diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index 80c22338ae0..f5376cb20ab 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -673,7 +673,8 @@ func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o adm // // the only time to add configuration checks here is if you need to compare // the incoming change to the current configuration - list := b.validator.ValidateRepository(repo) + isCreate := a.GetOperation() == admission.Create + list := b.validator.ValidateRepository(repo, isCreate) cfg := repo.Config() if a.GetOperation() == admission.Update { diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 00bcdabd88d..995a01ad825 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -2167,7 +2167,7 @@ func (cfg *Cfg) readProvisioningSettings(iniFile *ini.File) error { } cfg.ProvisioningAllowedTargets = iniFile.Section("provisioning").Key("allowed_targets").Strings("|") if len(cfg.ProvisioningAllowedTargets) == 0 { - cfg.ProvisioningAllowedTargets = []string{"instance", "folder"} + cfg.ProvisioningAllowedTargets = []string{"folder"} } cfg.ProvisioningAllowImageRendering = iniFile.Section("provisioning").Key("allow_image_rendering").MustBool(true) cfg.ProvisioningMinSyncInterval = iniFile.Section("provisioning").Key("min_sync_interval").MustDuration(10 * time.Second) diff --git a/pkg/tests/apis/provisioning/exportjob_test.go b/pkg/tests/apis/provisioning/exportjob_test.go index 2218e87ef77..5d4348d23cb 100644 --- a/pkg/tests/apis/provisioning/exportjob_test.go +++ b/pkg/tests/apis/provisioning/exportjob_test.go @@ -44,6 +44,7 @@ func TestIntegrationProvisioning_ExportUnifiedToRepository(t *testing.T) { const repo = "local-repository" testRepo := TestRepo{ Name: repo, + Target: "instance", // Export is only supported for instance sync Copies: map[string]string{}, // No initial files needed for export test ExpectedDashboards: 4, // 4 dashboards created above (v0, v1, v2alpha1, v2beta1) ExpectedFolders: 0, // No folders expected after sync @@ -177,6 +178,7 @@ func TestIntegrationProvisioning_ExportDashboardsWithStoredVersions(t *testing.T const repo = "version-test-repository" testRepo := TestRepo{ Name: repo, + Target: "instance", // Export is only supported for instance sync Copies: map[string]string{}, ExpectedDashboards: len(tests), ExpectedFolders: 0, diff --git a/pkg/tests/apis/provisioning/helper_test.go b/pkg/tests/apis/provisioning/helper_test.go index 0b6a5e3c806..791ac4b8a20 100644 --- a/pkg/tests/apis/provisioning/helper_test.go +++ b/pkg/tests/apis/provisioning/helper_test.go @@ -695,6 +695,9 @@ func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper }, }, PermittedProvisioningPaths: ".|" + provisioningPath, + // Allow both folder and instance sync targets for tests + // (instance is needed for export jobs, folder for most operations) + ProvisioningAllowedTargets: []string{"folder", "instance"}, } for _, o := range options { o(&opts) diff --git a/pkg/tests/apis/provisioning/job_validation_test.go b/pkg/tests/apis/provisioning/job_validation_test.go index c54ad58500c..4c1947693f1 100644 --- a/pkg/tests/apis/provisioning/job_validation_test.go +++ b/pkg/tests/apis/provisioning/job_validation_test.go @@ -24,10 +24,10 @@ func TestIntegrationProvisioning_JobValidation(t *testing.T) { const repo = "job-validation-test-repo" testRepo := TestRepo{ Name: repo, - Target: "instance", + Target: "folder", Copies: map[string]string{}, ExpectedDashboards: 0, - ExpectedFolders: 0, + ExpectedFolders: 1, // folder sync creates a folder } helper.CreateRepo(t, testRepo) diff --git a/pkg/tests/apis/provisioning/movejob_test.go b/pkg/tests/apis/provisioning/movejob_test.go index 85d227996fd..9cd298711a9 100644 --- a/pkg/tests/apis/provisioning/movejob_test.go +++ b/pkg/tests/apis/provisioning/movejob_test.go @@ -24,14 +24,15 @@ func TestIntegrationProvisioning_MoveJob(t *testing.T) { ctx := context.Background() const repo = "move-test-repo" testRepo := TestRepo{ - Name: repo, + Name: repo, + Target: "folder", Copies: map[string]string{ "testdata/all-panels.json": "dashboard1.json", "testdata/text-options.json": "dashboard2.json", "testdata/timeline-demo.json": "folder/dashboard3.json", }, ExpectedDashboards: 3, - ExpectedFolders: 1, + ExpectedFolders: 2, // folder sync creates a folder for the repo + one nested folder } helper.CreateRepo(t, testRepo) @@ -236,6 +237,7 @@ func TestIntegrationProvisioning_MoveJob(t *testing.T) { const refRepo = "move-ref-test-repo" helper.CreateRepo(t, TestRepo{ Name: refRepo, + Target: "folder", SkipResourceAssertions: true, // HACK: I am not sure why sometimes it's 6 or 3 dashbaords. }) diff --git a/pkg/tests/apis/provisioning/repository_test.go b/pkg/tests/apis/provisioning/repository_test.go index 877ed0d5f98..3cee3cf4aba 100644 --- a/pkg/tests/apis/provisioning/repository_test.go +++ b/pkg/tests/apis/provisioning/repository_test.go @@ -578,7 +578,13 @@ func TestIntegrationProvisioning_RunLocalRepository(t *testing.T) { const targetPath = "all-panels.json" // Set up the repository. - helper.CreateRepo(t, TestRepo{Name: repo}) + helper.CreateRepo(t, TestRepo{ + Name: repo, + Target: "folder", + ExpectedDashboards: 0, + ExpectedFolders: 1, // folder sync creates a folder for the repo + SkipResourceAssertions: false, + }) // Write a file -- this will create it *both* in the local file system, and in grafana t.Run("write all panels", func(t *testing.T) { @@ -744,10 +750,10 @@ func TestIntegrationProvisioning_ImportAllPanelsFromLocalRepository(t *testing.T // Set up the repository and the file to import. testRepo := TestRepo{ Name: repo, - Target: "instance", + Target: "folder", Copies: map[string]string{"testdata/all-panels.json": "all-panels.json"}, ExpectedDashboards: 1, - ExpectedFolders: 0, + ExpectedFolders: 1, // folder sync creates a folder } // We create the repository helper.CreateRepo(t, testRepo) diff --git a/pkg/tests/apis/provisioning/stats_test.go b/pkg/tests/apis/provisioning/stats_test.go index eae3cbf7531..25ecd5c8f7c 100644 --- a/pkg/tests/apis/provisioning/stats_test.go +++ b/pkg/tests/apis/provisioning/stats_test.go @@ -21,13 +21,14 @@ func TestIntegrationProvisioning_Stats(t *testing.T) { const repo = "stats-test-repo1" testRepo := TestRepo{ - Name: repo, + Name: repo, + Target: "folder", Copies: map[string]string{ "testdata/all-panels.json": "dashboard1.json", "testdata/text-options.json": "folder/dashboard2.json", }, ExpectedDashboards: 2, - ExpectedFolders: 1, + ExpectedFolders: 2, // folder sync creates a folder for the repo + one nested folder } helper.CreateRepo(t, testRepo) @@ -94,7 +95,7 @@ func TestIntegrationProvisioning_Stats(t *testing.T) { require.Equal(t, int64(2), count, "repo should manage 2 dashboards") } else if group == "folder.grafana.app" && resource == "folders" { count, _, _ := unstructured.NestedInt64(stat, "count") - require.Equal(t, int64(1), count, "repo should manage 1 folder") + require.Equal(t, int64(2), count, "repo should manage 2 folders (repo folder + nested folder)") } } } diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index c84ddd6fb40..6e2f0660c32 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -580,6 +580,12 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) { _, err = pathsSect.NewKey("permitted_provisioning_paths", opts.PermittedProvisioningPaths) require.NoError(t, err) } + if len(opts.ProvisioningAllowedTargets) > 0 { + provisioningSect, err := getOrCreateSection("provisioning") + require.NoError(t, err) + _, err = provisioningSect.NewKey("allowed_targets", strings.Join(opts.ProvisioningAllowedTargets, "|")) + require.NoError(t, err) + } if opts.EnableSCIM { scimSection, err := getOrCreateSection("auth.scim") require.NoError(t, err) @@ -669,6 +675,7 @@ type GrafanaOpts struct { UnifiedStorageEnableSearch bool UnifiedStorageMaxPageSizeBytes int PermittedProvisioningPaths string + ProvisioningAllowedTargets []string GrafanaComSSOAPIToken string LicensePath string EnableRecordingRules bool diff --git a/public/app/features/provisioning/Config/ConfigForm.tsx b/public/app/features/provisioning/Config/ConfigForm.tsx index a29bc68329e..9c86403fee6 100644 --- a/public/app/features/provisioning/Config/ConfigForm.tsx +++ b/public/app/features/provisioning/Config/ConfigForm.tsx @@ -80,10 +80,7 @@ export function ConfigForm({ data }: ConfigFormProps) { const [isLoading, setIsLoading] = useState(false); const navigate = useNavigate(); const [type, readOnly] = watch(['type', 'readOnly']); - const targetOptions = useMemo( - () => getTargetOptions(settings.data?.allowedTargets || ['instance', 'folder']), - [settings.data] - ); + const targetOptions = useMemo(() => getTargetOptions(settings.data?.allowedTargets || ['folder']), [settings.data]); const isGitBased = isGitProvider(type); const { diff --git a/public/app/features/provisioning/Config/defaults.ts b/public/app/features/provisioning/Config/defaults.ts index 7a8f27ae456..19d17fb4da7 100644 --- a/public/app/features/provisioning/Config/defaults.ts +++ b/public/app/features/provisioning/Config/defaults.ts @@ -11,7 +11,7 @@ export interface GetDefaultValuesOptions { export function getDefaultValues({ repository, - allowedTargets = ['instance', 'folder'], + allowedTargets = ['folder'], }: GetDefaultValuesOptions = {}): RepositoryFormData { if (!repository) { const defaultTarget = allowedTargets.includes('folder') ? 'folder' : 'instance'; diff --git a/public/app/features/provisioning/Shared/RepositoryList.tsx b/public/app/features/provisioning/Shared/RepositoryList.tsx index 038359c6e8b..76dab485a74 100644 --- a/public/app/features/provisioning/Shared/RepositoryList.tsx +++ b/public/app/features/provisioning/Shared/RepositoryList.tsx @@ -22,6 +22,7 @@ export function RepositoryList({ items }: Props) { const filteredItems = items.filter((item) => item.metadata?.name?.includes(query)); const { instanceConnected } = checkSyncSettings(items); + const hasInstanceSyncRepo = items.some((item) => item.spec?.sync?.target === 'instance'); const getResourceCountSection = () => { if (isProvisionedInstance) { @@ -77,6 +78,17 @@ export function RepositoryList({ items }: Props) { return ( <> {getResourceCountSection()} + {hasInstanceSyncRepo && ( + + + Instance sync is currently not fully supported and breaks library panels and alerts. To use library panels + and alerts, disconnect your repository and reconnect it using folder sync instead. + + + )} {!instanceConnected && ( diff --git a/public/app/features/provisioning/Wizard/BootstrapStep.test.tsx b/public/app/features/provisioning/Wizard/BootstrapStep.test.tsx index 90d566415b9..24d7e322ac3 100644 --- a/public/app/features/provisioning/Wizard/BootstrapStep.test.tsx +++ b/public/app/features/provisioning/Wizard/BootstrapStep.test.tsx @@ -32,7 +32,7 @@ function FormWrapper({ children, defaultValues }: { children: ReactNode; default url: 'https://github.com/test/repo', title: '', sync: { - target: 'instance', + target: 'folder', enabled: true, }, branch: 'main', @@ -101,12 +101,6 @@ describe('BootstrapStep', () => { (useModeOptions as jest.Mock).mockReturnValue({ enabledOptions: [ - { - target: 'instance', - label: 'Sync all resources with external storage', - description: 'Resources will be synced with external storage', - subtitle: 'Use this option if you want to sync your entire instance', - }, { target: 'folder', label: 'Sync external storage to a new Grafana folder', @@ -142,8 +136,8 @@ describe('BootstrapStep', () => { it('should render correct info for GitHub repository type', async () => { setup(); - expect(screen.getAllByText('External storage')).toHaveLength(2); - expect(screen.getAllByText('Empty')).toHaveLength(4); // Four elements should show "Empty" (2 external + 2 unmanaged, one per card) + expect(screen.getAllByText('External storage')).toHaveLength(1); // Only folder sync is shown by default + expect(screen.getAllByText('Empty')).toHaveLength(2); // Two elements should have the role "Empty" (1 external + 1 unmanaged) }); it('should render correct info for local file repository type', async () => { @@ -171,10 +165,12 @@ describe('BootstrapStep', () => { setup(); - expect(await screen.getAllByText('2 files')).toHaveLength(2); + expect(await screen.getAllByText('2 files')).toHaveLength(1); // Only folder sync is shown by default }); it('should display resource counts when resources exist', async () => { + // Note: Resource counts are only shown for instance sync, but instance sync is not available by default + // This test is kept for when instance sync is explicitly enabled via settings (useGetResourceStatsQuery as jest.Mock).mockReturnValue({ data: { instance: [ @@ -196,10 +192,29 @@ describe('BootstrapStep', () => { shouldSkipSync: false, }); - setup(); + // Mock settings to allow instance sync for this test + (useModeOptions as jest.Mock).mockReturnValue({ + enabledOptions: [ + { + target: 'instance', + label: 'Sync all resources with external storage', + description: 'Resources will be synced with external storage', + subtitle: 'Use this option if you want to sync your entire instance', + }, + ], + disabledOptions: [], + }); - // Two elements display "7 resources": one in the external storage card and one in unmanaged resources card - expect(await screen.findAllByText('7 resources')).toHaveLength(2); + setup({ + settingsData: { + allowedTargets: ['instance', 'folder'], + allowImageRendering: true, + items: [], + availableRepositoryTypes: [], + }, + }); + + expect(await screen.findByText('7 resources')).toBeInTheDocument(); }); }); @@ -208,16 +223,30 @@ describe('BootstrapStep', () => { setup(); const mockUseResourceStats = require('./hooks/useResourceStats').useResourceStats; - expect(mockUseResourceStats).toHaveBeenCalledWith('test-repo', 'instance'); + expect(mockUseResourceStats).toHaveBeenCalledWith('test-repo', 'folder'); + }); + + it('should use useResourceStats hook with settings data', async () => { + setup({ + settingsData: { + allowedTargets: ['folder'], + allowImageRendering: true, + items: [], + availableRepositoryTypes: [], + }, + }); + + const mockUseResourceStats = require('./hooks/useResourceStats').useResourceStats; + expect(mockUseResourceStats).toHaveBeenCalledWith('test-repo', 'folder'); }); }); describe('sync target options', () => { - it('should display both instance and folder options by default', async () => { + it('should display only folder option by default', async () => { setup(); - expect(await screen.findByText('Sync all resources with external storage')).toBeInTheDocument(); expect(await screen.findByText('Sync external storage to a new Grafana folder')).toBeInTheDocument(); + expect(screen.queryByText('Sync all resources with external storage')).not.toBeInTheDocument(); }); it('should only display instance option when legacy storage exists', async () => { @@ -242,6 +271,7 @@ describe('BootstrapStep', () => { setup({ settingsData: { + allowedTargets: ['instance', 'folder'], allowImageRendering: true, items: [], availableRepositoryTypes: [], @@ -264,15 +294,10 @@ describe('BootstrapStep', () => { }); describe('title field visibility', () => { - it('should show title field only for folder sync target', async () => { - const { user } = setup(); - - // Initially should not show title field (default is instance) - expect(screen.queryByRole('textbox', { name: /display name/i })).not.toBeInTheDocument(); - - const folderOption = await screen.findByText('Sync external storage to a new Grafana folder'); - await user.click(folderOption); + it('should show title field for folder sync target', async () => { + setup(); + // Default is folder, so title field should be visible expect(await screen.findByRole('textbox', { name: /display name/i })).toBeInTheDocument(); }); }); diff --git a/public/app/features/provisioning/Wizard/ProvisioningWizard.test.tsx b/public/app/features/provisioning/Wizard/ProvisioningWizard.test.tsx index 24d23acc607..db49c3bb86e 100644 --- a/public/app/features/provisioning/Wizard/ProvisioningWizard.test.tsx +++ b/public/app/features/provisioning/Wizard/ProvisioningWizard.test.tsx @@ -227,6 +227,15 @@ describe('ProvisioningWizard', () => { error: null, refetch: jest.fn(), }); + // Mock files to ensure sync step is not skipped for folder sync + mockUseGetRepositoryFilesQuery.mockReturnValue({ + data: { + items: [{ name: 'test.json', path: 'test.json' }], + }, + isLoading: false, + error: null, + refetch: jest.fn(), + }); const { user } = setup(); await fillConnectionForm(user, 'github', { @@ -500,10 +509,10 @@ describe('ProvisioningWizard', () => { }); it('should show button text changes based on current step', async () => { - // Mock resources to ensure sync step is not skipped - mockUseGetResourceStatsQuery.mockReturnValue({ + // Mock files to ensure sync step is not skipped for folder sync + mockUseGetRepositoryFilesQuery.mockReturnValue({ data: { - instance: [{ group: 'dashboard.grafana.app', count: 1 }], + items: [{ name: 'test.json', path: 'test.json' }], }, isLoading: false, error: null, diff --git a/public/app/features/provisioning/Wizard/hooks/useModeOptions.ts b/public/app/features/provisioning/Wizard/hooks/useModeOptions.ts index 4bf817b091e..3d288ab487d 100644 --- a/public/app/features/provisioning/Wizard/hooks/useModeOptions.ts +++ b/public/app/features/provisioning/Wizard/hooks/useModeOptions.ts @@ -10,7 +10,7 @@ import { ModeOption } from '../types'; */ function filterModeOptions(modeOptions: ModeOption[], repoName: string, settings?: RepositoryViewList): ModeOption[] { const folderConnected = settings?.items?.some((item) => item.target === 'folder' && item.name !== repoName); - const allowedTargets = settings?.allowedTargets || ['instance', 'folder']; + const allowedTargets = settings?.allowedTargets || ['folder']; return modeOptions.map((option) => { if (option.disabled) { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index febd4c0442e..00757c4251d 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -11951,6 +11951,10 @@ "unsupported-repository-type": "Unsupported repository type: {{repositoryType}}" }, "inline-secure-values-warning": "You need to save your access tokens again due to a system update", + "instance-sync-deprecation": { + "message": "Instance sync is currently not fully supported and breaks library panels and alerts. To use library panels and alerts, disconnect your repository and reconnect it using folder sync instead.", + "title": "Instance sync is not fully supported" + }, "job-status": { "label-view-details": "View details", "loading-finished-job": "Loading finished job...", From b4eb02a6f0ec6e8a21f8bd8613cfc9be4fa3dbaa Mon Sep 17 00:00:00 2001 From: Yulia Shanyrova Date: Fri, 19 Dec 2025 12:45:15 +0100 Subject: [PATCH 044/229] Plugins: Change pageId parameter type in usePluginDetailsTabs (#115612) * change usePluginDetailsTabs pageId parameter type * add eslint suppressions --- eslint-suppressions.json | 5 ----- .../plugins/admin/components/PluginDetailsPage.tsx | 7 +------ .../features/plugins/admin/hooks/usePluginDetailsTabs.tsx | 8 ++++---- 3 files changed, 5 insertions(+), 15 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 1d0d5a2684c..36eb78aaa72 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2868,11 +2868,6 @@ "count": 1 } }, - "public/app/features/plugins/admin/components/PluginDetailsPage.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, "public/app/features/plugins/admin/helpers.ts": { "no-restricted-syntax": { "count": 2 diff --git a/public/app/features/plugins/admin/components/PluginDetailsPage.tsx b/public/app/features/plugins/admin/components/PluginDetailsPage.tsx index 0e651a8e4bf..e4252407ce5 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsPage.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsPage.tsx @@ -17,7 +17,6 @@ import { PluginDetailsSignature } from '../components/PluginDetailsSignature'; import { usePluginDetailsTabs } from '../hooks/usePluginDetailsTabs'; import { usePluginPageExtensions } from '../hooks/usePluginPageExtensions'; import { useGetSingle, useFetchStatus, useFetchDetailsStatus } from '../state/hooks'; -import { PluginTabIds } from '../types'; import { PluginDetailsDeprecatedWarning } from './PluginDetailsDeprecatedWarning'; @@ -50,11 +49,7 @@ export function PluginDetailsPage({ const queryParams = new URLSearchParams(location.search); const plugin = useGetSingle(pluginId); // fetches the plugin settings for this Grafana instance const isNarrowScreen = useMedia('(max-width: 600px)'); - const { navModel, activePageId } = usePluginDetailsTabs( - plugin, - queryParams.get('page') as PluginTabIds, - isNarrowScreen - ); + const { navModel, activePageId } = usePluginDetailsTabs(plugin, queryParams.get('page'), isNarrowScreen); const { actions, info, subtitle } = usePluginPageExtensions(plugin); const { isLoading: isFetchLoading } = useFetchStatus(); const { isLoading: isFetchDetailsLoading } = useFetchDetailsStatus(); diff --git a/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx b/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx index ab616988e3f..6cb8ec39af4 100644 --- a/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx +++ b/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx @@ -18,9 +18,9 @@ type ReturnType = { }; function getCurrentPageId( - pageId: PluginTabIds | undefined, isNarrowScreen: boolean | undefined, - defaultTab: string + defaultTab: string, + pageId?: PluginTabIds | string | null ): PluginTabIds | string { if (!isNarrowScreen && pageId === PluginTabIds.PLUGINDETAILS) { return defaultTab; @@ -30,7 +30,7 @@ function getCurrentPageId( export const usePluginDetailsTabs = ( plugin?: CatalogPlugin, - pageId?: PluginTabIds, + pageId?: PluginTabIds | string | null, isNarrowScreen?: boolean ): ReturnType => { const { loading, error, value: pluginConfig } = usePluginConfig(plugin); @@ -38,7 +38,7 @@ export const usePluginDetailsTabs = ( const defaultTab = useDefaultPage(plugin, pluginConfig); const isPublished = Boolean(plugin?.isPublished); - const currentPageId = getCurrentPageId(pageId, isNarrowScreen, defaultTab); + const currentPageId = getCurrentPageId(isNarrowScreen, defaultTab, pageId); const navModelChildren = useMemo(() => { const canConfigurePlugins = plugin && contextSrv.hasPermissionInMetadata(AccessControlAction.PluginsWrite, plugin); From c2275f6ee4a4b97f21a5e0f0622dd769e4fdf16c Mon Sep 17 00:00:00 2001 From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:03:45 +0100 Subject: [PATCH 045/229] Alerting: Add Cursor frontmatter to CLAUDE.md for auto-loading (#115613) add Cursor frontmatter to CLAUDE.md for auto-loading --- public/app/features/alerting/unified/CLAUDE.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/public/app/features/alerting/unified/CLAUDE.md b/public/app/features/alerting/unified/CLAUDE.md index 124b9da622f..35ea0486876 100644 --- a/public/app/features/alerting/unified/CLAUDE.md +++ b/public/app/features/alerting/unified/CLAUDE.md @@ -1,3 +1,10 @@ +--- +title: Alerting Squad Guidelines +description: Alerting-specific patterns and conventions for Grafana +globs: + - 'public/app/features/alerting/**' +--- + # Alerting Squad - Claude Code Configuration This file provides context for Claude Code when working on the Grafana Alerting codebase. It contains alerting-specific patterns and references to Grafana's coding standards. From e9a2828f668f88d417c9fc00d0c2b581c65aadd8 Mon Sep 17 00:00:00 2001 From: Yulia Shanyrova Date: Fri, 19 Dec 2025 13:40:41 +0100 Subject: [PATCH 046/229] Plugins: Add PluginInsights UI (#115616) * Add getInsights endpoint, add new component PluginInsights * fix linting and add styles * add version option to insights request * Add plugininsights tests, remove console.logs * fix the insight items types * Add getting insights to all the mocks to fix the tests * remove deprecated lint package * Add theme colors, added tests to PluginDetailsPanel * Fix eslint error for plugin details page * Add pluginInsights feature toggle * change getInsights with version API call, resolve conflicts with main * fix typecheck and translation * updated UI * update registry go * fix translation * light css changes * remove duplicated feature toggle * fix the build * update plugin insights tests * fix typecheck * rudderstack added, feedback form added * fix translation * Remove isPluginTabId function --- .../src/types/featureToggles.gen.ts | 5 + pkg/services/featuremgmt/registry.go | 8 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.json | 14 ++ public/app/features/plugins/admin/api.ts | 16 ++ .../components/PluginDetailsPage.test.tsx | 2 + .../admin/components/PluginDetailsPage.tsx | 4 +- .../components/PluginDetailsPanel.test.tsx | 71 +++++++- .../admin/components/PluginDetailsPanel.tsx | 9 +- .../admin/components/PluginInsights.test.tsx | 171 ++++++++++++++++++ .../admin/components/PluginInsights.tsx | 140 ++++++++++++++ .../plugins/admin/mocks/catalogPlugin.mock.ts | 2 + .../plugins/admin/mocks/mockHelpers.ts | 8 + .../features/plugins/admin/state/actions.ts | 20 +- .../app/features/plugins/admin/state/hooks.ts | 29 ++- .../features/plugins/admin/state/reducer.ts | 5 + public/app/features/plugins/admin/types.ts | 49 +++++ public/locales/en-US/grafana.json | 6 + 18 files changed, 554 insertions(+), 6 deletions(-) create mode 100644 public/app/features/plugins/admin/components/PluginInsights.test.tsx create mode 100644 public/app/features/plugins/admin/components/PluginInsights.tsx diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index ea9f1c22f39..19a8fbf2c44 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1193,6 +1193,11 @@ export interface FeatureToggles { */ onlyStoreActionSets?: boolean; /** + * Show insights for plugins in the plugin details page + * @default false + */ + pluginInsights?: boolean; + /** * Enables a new panel time settings drawer */ panelTimeSettings?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index c5aaf9fbf63..7e876849dfe 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1968,6 +1968,14 @@ var ( Owner: identityAccessTeam, Expression: "true", }, + { + Name: "pluginInsights", + Description: "Show insights for plugins in the plugin details page", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaPluginsPlatformSquad, + Expression: "false", + }, { Name: "panelTimeSettings", Description: "Enables a new panel time settings drawer", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 6889cb9c040..510c05a815b 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -267,6 +267,7 @@ jaegerEnableGrpcEndpoint,experimental,@grafana/oss-big-tent,false,false,false pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,false,false newPanelPadding,preview,@grafana/dashboards-squad,false,false,true onlyStoreActionSets,GA,@grafana/identity-access-team,false,false,false +pluginInsights,experimental,@grafana/plugins-platform-backend,false,false,true panelTimeSettings,experimental,@grafana/dashboards-squad,false,false,false elasticsearchRawDSLQuery,experimental,@grafana/partner-datasources,false,false,false kubernetesAnnotations,experimental,@grafana/grafana-backend-services-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 63257b6d738..0db4a887a6a 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2720,6 +2720,20 @@ "expression": "false" } }, + { + "metadata": { + "name": "pluginInsights", + "resourceVersion": "1761300628147", + "creationTimestamp": "2025-10-24T10:10:28Z" + }, + "spec": { + "description": "Show insights for plugins in the plugin details page", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend", + "frontend": true, + "expression": "false" + } + }, { "metadata": { "name": "pluginInstallAPISync", diff --git a/public/app/features/plugins/admin/api.ts b/public/app/features/plugins/admin/api.ts index 74a072ba054..aa5bc32f183 100644 --- a/public/app/features/plugins/admin/api.ts +++ b/public/app/features/plugins/admin/api.ts @@ -8,6 +8,7 @@ import { LocalPlugin, RemotePlugin, CatalogPluginDetails, + CatalogPluginInsights, Version, PluginVersion, InstancePlugin, @@ -47,6 +48,21 @@ export async function getPluginDetails(id: string): Promise { + if (!version) { + throw new Error('Version is required'); + } + try { + const insights = await getBackendSrv().get(`${GCOM_API_ROOT}/plugins/${id}/versions/${version}/insights`); + return insights; + } catch (error) { + if (isFetchError(error)) { + error.isHandled = true; + } + throw error; + } +} + export async function getRemotePlugins(): Promise { try { const { items: remotePlugins }: { items: RemotePlugin[] } = await getBackendSrv().get(`${GCOM_API_ROOT}/plugins`, { diff --git a/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx b/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx index da4eef2f0d4..0ffc93f8f77 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx @@ -62,10 +62,12 @@ const plugin: CatalogPlugin = { angularDetected: false, isFullyInstalled: true, accessControl: {}, + insights: { id: 1, name: 'test-plugin', version: '1.0.0', insights: [] }, }; jest.mock('../state/hooks', () => ({ useGetSingle: jest.fn(), + useGetPluginInsights: jest.fn(), useFetchStatus: jest.fn().mockReturnValue({ isLoading: false }), useFetchDetailsStatus: () => ({ isLoading: false }), useIsRemotePluginsAvailable: () => false, diff --git a/public/app/features/plugins/admin/components/PluginDetailsPage.tsx b/public/app/features/plugins/admin/components/PluginDetailsPage.tsx index e4252407ce5..2a7342e6be8 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsPage.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsPage.tsx @@ -16,7 +16,7 @@ import { PluginDetailsPanel } from '../components/PluginDetailsPanel'; import { PluginDetailsSignature } from '../components/PluginDetailsSignature'; import { usePluginDetailsTabs } from '../hooks/usePluginDetailsTabs'; import { usePluginPageExtensions } from '../hooks/usePluginPageExtensions'; -import { useGetSingle, useFetchStatus, useFetchDetailsStatus } from '../state/hooks'; +import { useGetSingle, useFetchStatus, useFetchDetailsStatus, useGetPluginInsights } from '../state/hooks'; import { PluginDetailsDeprecatedWarning } from './PluginDetailsDeprecatedWarning'; @@ -48,6 +48,8 @@ export function PluginDetailsPage({ }; const queryParams = new URLSearchParams(location.search); const plugin = useGetSingle(pluginId); // fetches the plugin settings for this Grafana instance + useGetPluginInsights(pluginId, plugin?.isInstalled ? plugin?.installedVersion : plugin?.latestVersion); + const isNarrowScreen = useMedia('(max-width: 600px)'); const { navModel, activePageId } = usePluginDetailsTabs(plugin, queryParams.get('page'), isNarrowScreen); const { actions, info, subtitle } = usePluginPageExtensions(plugin); diff --git a/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx b/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx index eade37f559c..20787099842 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx @@ -1,11 +1,23 @@ +import userEvent from '@testing-library/user-event'; import { render, screen } from 'test/test-utils'; import { PluginSignatureStatus, PluginSignatureType, PluginType } from '@grafana/data'; +import { config } from '@grafana/runtime'; -import { CatalogPlugin } from '../types'; +import { CatalogPlugin, SCORE_LEVELS } from '../types'; import { PluginDetailsPanel } from './PluginDetailsPanel'; +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + config: { + ...jest.requireActual('@grafana/runtime').config, + featureToggles: { + pluginInsights: false, + }, + }, +})); + const mockPlugin: CatalogPlugin = { description: 'Test plugin description', downloads: 1000, @@ -185,4 +197,61 @@ describe('PluginDetailsPanel', () => { expect(regularLinks).toContainElement(raiseIssueLink); expect(regularLinks).not.toContainElement(websiteLink); }); + + it('should render plugin insights when plugin has insights', async () => { + config.featureToggles.pluginInsights = true; + const pluginWithInsights = { + ...mockPlugin, + insights: { + id: 1, + name: 'test-plugin', + version: '1.0.0', + insights: [ + { + name: 'security', + scoreValue: 90, + scoreLevel: SCORE_LEVELS.EXCELLENT, + items: [ + { + id: 'signature', + name: 'Signature verified', + level: 'ok' as const, + }, + ], + }, + ], + }, + }; + render(); + expect(screen.getByTestId('plugin-insights-container')).toBeInTheDocument(); + expect(screen.getByText('Plugin insights')).toBeInTheDocument(); + expect(screen.queryByText('Security')).toBeInTheDocument(); + await userEvent.click(screen.getByText('Security')); + expect(screen.getByTestId('plugin-insight-item-signature')).toBeInTheDocument(); + }); + + it('should not render plugin insights when plugin has no insights', () => { + const pluginWithoutInsights = { + ...mockPlugin, + insights: undefined, + }; + render(); + expect(screen.queryByTestId('plugin-insights-container')).not.toBeInTheDocument(); + expect(screen.queryByText('Plugin insights')).not.toBeInTheDocument(); + }); + + it('should not render plugin insights when insights array is empty', () => { + const pluginWithEmptyInsights = { + ...mockPlugin, + insights: { + id: 1, + name: 'test-plugin', + version: '1.0.0', + insights: [], + }, + }; + render(); + expect(screen.queryByTestId('plugin-insights-container')).not.toBeInTheDocument(); + expect(screen.queryByText('Plugin insights')).not.toBeInTheDocument(); + }); }); diff --git a/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx b/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx index 00211b61c6e..aa8b6c792ef 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; -import { reportInteraction } from '@grafana/runtime'; +import { config, reportInteraction } from '@grafana/runtime'; import { PageInfoItem } from '@grafana/runtime/internal'; import { Stack, @@ -22,6 +22,8 @@ import { formatDate } from 'app/core/internationalization/dates'; import { CatalogPlugin } from '../types'; +import { PluginInsights } from './PluginInsights'; + type Props = { pluginExtentionsInfo: PageInfoItem[]; plugin: CatalogPlugin; width?: string }; export function PluginDetailsPanel(props: Props): React.ReactElement | null { @@ -69,6 +71,11 @@ export function PluginDetailsPanel(props: Props): React.ReactElement | null { return ( <> + {config.featureToggles.pluginInsights && plugin.insights && plugin.insights?.insights?.length > 0 && ( + + + + )} {pluginExtentionsInfo.map((infoItem, index) => { diff --git a/public/app/features/plugins/admin/components/PluginInsights.test.tsx b/public/app/features/plugins/admin/components/PluginInsights.test.tsx new file mode 100644 index 00000000000..efd064c7172 --- /dev/null +++ b/public/app/features/plugins/admin/components/PluginInsights.test.tsx @@ -0,0 +1,171 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen } from 'test/test-utils'; + +import { CatalogPluginInsights, InsightLevel, SCORE_LEVELS } from '../types'; + +import { PluginInsights } from './PluginInsights'; + +const mockPluginInsights: CatalogPluginInsights = { + id: 1, + name: 'test-plugin', + version: '1.0.0', + insights: [ + { + name: 'security', + scoreValue: 90, + scoreLevel: SCORE_LEVELS.EXCELLENT, + items: [ + { + id: 'signature', + name: 'Signature verified', + description: 'Plugin signature is valid', + level: 'ok' as InsightLevel, + }, + { + id: 'trackingscripts', + name: 'No unsafe JavaScript detected', + level: 'good' as InsightLevel, + }, + ], + }, + { + name: 'quality', + scoreValue: 60, + scoreLevel: SCORE_LEVELS.FAIR, + items: [ + { + id: 'metadatavalid', + name: 'Metadata is valid', + level: 'ok' as InsightLevel, + }, + { + id: 'code-rules', + name: 'Missing code rules', + description: 'Plugin lacks comprehensive code rules', + level: 'warning' as InsightLevel, + }, + ], + }, + ], +}; + +const mockPluginInsightsWithPoorLevel: CatalogPluginInsights = { + id: 3, + name: 'test-plugin-poor', + version: '0.8.0', + insights: [ + { + name: 'quality', + scoreValue: 35, + scoreLevel: SCORE_LEVELS.POOR, + items: [ + { + id: 'legacy-platform', + name: 'Quality issues detected', + level: 'warning' as InsightLevel, + }, + ], + }, + ], +}; + +describe('PluginInsights', () => { + it('should render plugin insights section', () => { + render(); + const insightsSection = screen.getByTestId('plugin-insights-container'); + expect(insightsSection).toBeInTheDocument(); + expect(screen.getByText('Plugin insights')).toBeInTheDocument(); + }); + + it('should render all insight categories with test ids', () => { + render(); + expect(screen.getByTestId('plugin-insight-security')).toBeInTheDocument(); + expect(screen.getByTestId('plugin-insight-quality')).toBeInTheDocument(); + }); + + it('should render category names with test ids', () => { + render(); + const securityCategory = screen.getByTestId('plugin-insight-security'); + const qualityCategory = screen.getByTestId('plugin-insight-quality'); + + expect(securityCategory).toBeInTheDocument(); + expect(securityCategory).toHaveTextContent('Security'); + expect(qualityCategory).toBeInTheDocument(); + expect(qualityCategory).toHaveTextContent('Quality'); + }); + + it('should render individual insight items with test ids', async () => { + render(); + await userEvent.click(screen.getByText('Security')); + expect(screen.getByTestId('plugin-insight-item-signature')).toBeInTheDocument(); + expect(screen.getByTestId('plugin-insight-item-trackingscripts')).toBeInTheDocument(); + await userEvent.click(screen.getByText('Quality')); + expect(screen.getByTestId('plugin-insight-item-metadatavalid')).toBeInTheDocument(); + expect(screen.getByTestId('plugin-insight-item-code-rules')).toBeInTheDocument(); + }); + + it('should display correct icons for Excellent score level', () => { + render(); + + const securityCategory = screen.getByTestId('plugin-insight-security'); + const securityIcon = securityCategory.querySelector('[data-testid="excellent-icon"]'); + expect(securityIcon).toBeInTheDocument(); + }); + + it('should display correct icons for Poor score levels', () => { + // Test Poor level - should show exclamation-triangle + render(); + const poorCategory = screen.getByTestId('plugin-insight-quality'); + const poorIcon = poorCategory.querySelector('[data-testid="poor-icon"]'); + expect(poorIcon).toBeInTheDocument(); + }); + + it('should handle multiple items with different insight levels', async () => { + const multiLevelInsights: CatalogPluginInsights = { + id: 5, + name: 'multi-level-plugin', + version: '2.0.0', + insights: [ + { + name: 'quality', + scoreValue: 75, + scoreLevel: SCORE_LEVELS.GOOD, + items: [ + { + id: 'code-rules', + name: 'Info level item', + level: 'info' as InsightLevel, + }, + { + id: 'sdk-usage', + name: 'OK level item', + level: 'ok' as InsightLevel, + }, + { + id: 'jsMap', + name: 'Good level item', + level: 'good' as InsightLevel, + }, + { + id: 'gosec', + name: 'Warning level item', + level: 'warning' as InsightLevel, + }, + { + id: 'legacy-builder', + name: 'Danger level item', + level: 'danger' as InsightLevel, + }, + ], + }, + ], + }; + render(); + await userEvent.click(screen.getByText('Quality')); + expect(screen.getByText('Info level item')).toBeInTheDocument(); + expect(screen.getByText('OK level item')).toBeInTheDocument(); + expect(screen.getByText('Good level item')).toBeInTheDocument(); + expect(screen.getByText('Warning level item')).toBeInTheDocument(); + expect(screen.getByText('Danger level item')).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/plugins/admin/components/PluginInsights.tsx b/public/app/features/plugins/admin/components/PluginInsights.tsx new file mode 100644 index 00000000000..805bcf926bf --- /dev/null +++ b/public/app/features/plugins/admin/components/PluginInsights.tsx @@ -0,0 +1,140 @@ +import { css } from '@emotion/css'; +import { capitalize } from 'lodash'; +import { useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; +import { reportInteraction } from '@grafana/runtime'; +import { Stack, Text, TextLink, CollapsableSection, Tooltip, Icon, useStyles2, useTheme2 } from '@grafana/ui'; + +import { CatalogPluginInsights } from '../types'; + +type Props = { pluginInsights: CatalogPluginInsights | undefined }; + +const PLUGINS_INSIGHTS_OPENED_EVENT_NAME = 'plugins_insights_opened'; + +export function PluginInsights(props: Props): React.ReactElement | null { + const { pluginInsights } = props; + const styles = useStyles2(getStyles); + const theme = useTheme2(); + const [openInsights, setOpenInsights] = useState>({}); + + const handleInsightToggle = (insightName: string, isOpen: boolean) => { + if (isOpen) { + reportInteraction(PLUGINS_INSIGHTS_OPENED_EVENT_NAME, { insight: insightName }); + } + setOpenInsights((prev) => ({ ...prev, [insightName]: isOpen })); + }; + + const tooltipInfo = ( + + + + + + All relevant signals are present and verified + + + + + + + + One or more signals are missing or need attention + + + +
+ + + Do you find Plugin Insights usefull? Please share your feedback{' '} + + here + + . + + +
+ ); + + return ( + <> + + + + Plugin insights + + + + + + {pluginInsights?.insights.map((insightItem, index) => { + return ( + + handleInsightToggle(insightItem.name, isOpen)} + label={ + + {insightItem.scoreLevel === 'Excellent' ? ( + + ) : ( + + )} + + {capitalize(insightItem.name)} + + + } + contentClassName={styles.pluginInsightsItems} + > + + {insightItem.items.map((item, idx) => ( + + + {item.level === 'good' ? ( + + ) : ( + + )} + + + {item.name} + + + ))} + + + + ); + })} + + + ); +} + +export const getStyles = (theme: GrafanaTheme2) => { + return { + pluginVersionDetails: css({ wordBreak: 'break-word' }), + pluginInsightsItems: css({ marginLeft: '26px', paddingTop: '0 !important' }), + pluginInsightsTooltipSeparator: css({ + border: 'none', + borderTop: `1px solid ${theme.colors.border.medium}`, + margin: `${theme.spacing(1)} 0`, + }), + }; +}; diff --git a/public/app/features/plugins/admin/mocks/catalogPlugin.mock.ts b/public/app/features/plugins/admin/mocks/catalogPlugin.mock.ts index 9ced4f20a84..3625b687f7b 100644 --- a/public/app/features/plugins/admin/mocks/catalogPlugin.mock.ts +++ b/public/app/features/plugins/admin/mocks/catalogPlugin.mock.ts @@ -34,6 +34,7 @@ export default { updatedAt: '2021-08-25T15:03:49.000Z', version: '4.2.2', error: undefined, + insights: { id: 1, name: 'alexanderzobnin-zabbix-app', version: '4.2.2', insights: [] }, details: { grafanaDependency: '>=8.0.0', pluginDependencies: [], @@ -381,6 +382,7 @@ export const datasourcePlugin = { angularDetected: false, isFullyInstalled: true, latestVersion: '1.20.0', + insights: { id: 2, name: 'grafana-redshift-datasource', version: '1.20.0', insights: [] }, details: { grafanaDependency: '>=8.0.0', pluginDependencies: [], diff --git a/public/app/features/plugins/admin/mocks/mockHelpers.ts b/public/app/features/plugins/admin/mocks/mockHelpers.ts index 6034e8860e9..d6e04186f77 100644 --- a/public/app/features/plugins/admin/mocks/mockHelpers.ts +++ b/public/app/features/plugins/admin/mocks/mockHelpers.ts @@ -31,6 +31,9 @@ export const getPluginsStateMock = (plugins: CatalogPlugin[] = []): ReducerState 'plugins/fetchDetails': { status: RequestStatus.Fulfilled, }, + 'plugins/fetchPluginInsights': { + status: RequestStatus.Fulfilled, + }, }, // Backward compatibility plugins: [], @@ -75,6 +78,11 @@ export const mockPluginApis = ({ return Promise.resolve({ items: versions }); } + // Mock plugin insights - return empty insights to avoid API call errors + if (path.includes('/insights')) { + return Promise.resolve({ id: 1, name: '', version: '', insights: [] }); + } + // Mock local plugin settings (installed) if necessary if (local && path === `${API_ROOT}/${local.id}/settings`) { return Promise.resolve(local); diff --git a/public/app/features/plugins/admin/state/actions.ts b/public/app/features/plugins/admin/state/actions.ts index 6be68111dd5..e9cf2d9d40d 100644 --- a/public/app/features/plugins/admin/state/actions.ts +++ b/public/app/features/plugins/admin/state/actions.ts @@ -13,6 +13,7 @@ import { getPluginErrors, getLocalPlugins, getPluginDetails, + getPluginInsights, installPlugin, uninstallPlugin, getInstancePlugins, @@ -165,6 +166,22 @@ export const fetchDetails = createAsyncThunk, stri } ); +export const fetchPluginInsights = createAsyncThunk, { id: string; version?: string }>( + `${STATE_PREFIX}/fetchPluginInsights`, + async ({ id, version }, thunkApi) => { + try { + const insights = await getPluginInsights(id, version); + + return { + id, + changes: { insights }, + }; + } catch (e) { + return thunkApi.rejectWithValue('Unknown error.'); + } + } +); + export const addPlugins = createAction(`${STATE_PREFIX}/addPlugins`); // 1. gets remote equivalents from the store (if there are any) @@ -265,7 +282,8 @@ export const panelPluginLoaded = createAction(`${STATE_PREFIX}/pane // TODO export const loadPanelPlugin = (id: string): ThunkResult> => { return async (dispatch, getStore) => { - let plugin = getStore().plugins.panels[id]; + const state = getStore(); + let plugin = state.plugins.panels[id]; if (!plugin) { plugin = await importPanelPlugin(id); diff --git a/public/app/features/plugins/admin/state/hooks.ts b/public/app/features/plugins/admin/state/hooks.ts index 2185ec99465..6eb47d7e1aa 100644 --- a/public/app/features/plugins/admin/state/hooks.ts +++ b/public/app/features/plugins/admin/state/hooks.ts @@ -6,7 +6,16 @@ import { useDispatch, useSelector } from 'app/types/store'; import { sortPlugins, Sorters, isPluginUpdatable } from '../helpers'; import { CatalogPlugin, PluginStatus } from '../types'; -import { fetchAll, fetchDetails, fetchRemotePlugins, install, uninstall, fetchAllLocal, unsetInstall } from './actions'; +import { + fetchAll, + fetchDetails, + fetchRemotePlugins, + install, + uninstall, + fetchAllLocal, + unsetInstall, + fetchPluginInsights, +} from './actions'; import { selectPlugins, selectById, @@ -44,13 +53,18 @@ export const useGetUpdatable = () => { }; }; -export const useGetSingle = (id: string): CatalogPlugin | undefined => { +export const useGetSingle = (id: string, version?: string): CatalogPlugin | undefined => { useFetchAll(); useFetchDetails(id); return useSelector((state) => selectById(state, id)); }; +export const useGetPluginInsights = (id: string, version: string | undefined): CatalogPlugin | undefined => { + useFetchPluginInsights(id, version); + return useSelector((state) => selectById(state, id)); +}; + export const useGetSingleLocalWithoutDetails = (id: string): CatalogPlugin | undefined => { useFetchAllLocal(); return useSelector((state) => selectById(state, id)); @@ -153,6 +167,17 @@ export const useFetchDetails = (id: string) => { }, [plugin]); // eslint-disable-line }; +export const useFetchPluginInsights = (id: string, version: string | undefined) => { + const dispatch = useDispatch(); + const plugin = useSelector((state) => selectById(state, id)); + const isNotFetching = !useSelector(selectIsRequestPending(fetchPluginInsights.typePrefix)); + const shouldFetch = isNotFetching && plugin && !plugin.insights && version; + + useEffect(() => { + shouldFetch && dispatch(fetchPluginInsights({ id, version })); + }, [plugin, version]); // eslint-disable-line +}; + export const useFetchDetailsLazy = () => { const dispatch = useDispatch(); diff --git a/public/app/features/plugins/admin/state/reducer.ts b/public/app/features/plugins/admin/state/reducer.ts index f2414a31405..e3d5bec5427 100644 --- a/public/app/features/plugins/admin/state/reducer.ts +++ b/public/app/features/plugins/admin/state/reducer.ts @@ -7,6 +7,7 @@ import { CatalogPlugin, ReducerState, RequestStatus } from '../types'; import { fetchDetails, + fetchPluginInsights, install, uninstall, loadPluginDashboards, @@ -63,6 +64,10 @@ const slice = createSlice({ .addCase(fetchDetails.fulfilled, (state, action) => { pluginsAdapter.updateOne(state.items, action.payload); }) + // Fetch Plugin Insights + .addCase(fetchPluginInsights.fulfilled, (state, action) => { + pluginsAdapter.updateOne(state.items, action.payload); + }) // Install .addCase(install.fulfilled, (state, action) => { pluginsAdapter.updateOne(state.items, action.payload); diff --git a/public/app/features/plugins/admin/types.ts b/public/app/features/plugins/admin/types.ts index 3cc66bba0b9..df4114101b4 100644 --- a/public/app/features/plugins/admin/types.ts +++ b/public/app/features/plugins/admin/types.ts @@ -55,6 +55,7 @@ export interface CatalogPlugin extends WithAccessControlMetadata { updatedAt: string; installedVersion?: string; details?: CatalogPluginDetails; + insights?: CatalogPluginInsights; error?: PluginErrorCode; angularDetected?: boolean; // instance plugins may not be fully installed, which means a new instance @@ -90,6 +91,54 @@ export interface CatalogPluginDetails { screenshots?: Screenshots[] | null; } +export type InsightLevel = 'ok' | 'warning' | 'danger' | 'good' | 'info'; + +export const SCORE_LEVELS = { + EXCELLENT: 'Excellent', + GOOD: 'Good', + FAIR: 'Fair', + POOR: 'Poor', + CRITICAL: 'Critical', +} as const; + +export type ScoreLevel = (typeof SCORE_LEVELS)[keyof typeof SCORE_LEVELS]; + +export const INSIGHT_CATEGORIES = { + SECURITY: 'security', + QUALITY: 'quality', + PERFORMANCE: 'performance', +} as const; + +export const INSIGHT_LEVELS = { + GOOD: 'good', + OK: 'ok', + WARNING: 'warning', + DANGER: 'danger', + INFO: 'info', +} as const; + +export interface InsightItem { + id: string; + name: string; + description?: string; + level: InsightLevel; + link?: string; +} + +export interface InsightCategory { + name: string; + items: InsightItem[]; + scoreValue: number; + scoreLevel: ScoreLevel; +} + +export interface CatalogPluginInsights { + id: number; + name: string; + version: string; + insights: InsightCategory[]; +} + export interface CatalogPluginInfo { logos: { large: string; small: string }; keywords: string[]; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 00757c4251d..53abfa01d14 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -11406,6 +11406,12 @@ "latestReleaseDate": "Latest release date:", "latestVersion": "Latest Version", "license": "License", + "moreDetails": "Do you find Plugin Insights usefull? Please share your feedback <2>here.", + "pluginInsights": { + "header": "Plugin insights" + }, + "pluginInsightsSuccessTooltip": "All relevant signals are present and verified", + "pluginInsightsWarningTooltip": "One or more signals are missing or need attention", "raiseAnIssue": "Raise an issue", "reportAbuse": "Report a concern", "reportAbuseTooltip": "Report issues related to malicious or harmful plugins directly to Grafana Labs.", From ece38641cabf9dc0d4678053fe02b024968123e9 Mon Sep 17 00:00:00 2001 From: Marcus Andersson Date: Fri, 19 Dec 2025 13:48:53 +0100 Subject: [PATCH 047/229] Dashboards: Make sure to render dashboard links even if they are marked as "in controls menu" (#115381) links with type dashboard will now be visible. --- .../dashboard-scene/scene/dashboard-controls-menu/utils.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/public/app/features/dashboard-scene/scene/dashboard-controls-menu/utils.tsx b/public/app/features/dashboard-scene/scene/dashboard-controls-menu/utils.tsx index 6514ea34ed9..04d0f7078c8 100644 --- a/public/app/features/dashboard-scene/scene/dashboard-controls-menu/utils.tsx +++ b/public/app/features/dashboard-scene/scene/dashboard-controls-menu/utils.tsx @@ -5,11 +5,7 @@ import { isDashboardDataLayerSetState } from '../DashboardDataLayerSet'; import { DashboardScene } from '../DashboardScene'; export function getDashboardControlsLinks(links: DashboardLink[]) { - // Dashboard links are not supported at the moment. - // Reason: nesting components causes issues since the inner dropdown is rendered in a portal, - // so clicking it closes the parent dropdown (the parent sees it as an overlay click, and the event cannot easily be intercepted, - // as it is in different HTML subtree). - return links.filter((link) => link.placement === 'inControlsMenu' && link.type !== 'dashboards'); + return links.filter((link) => link.placement === 'inControlsMenu'); } export function getDashboardControlsVariables(variables: SceneVariable[]) { From 9760eef62f5237c24bfc6681a0ec2457f547024d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Jim=C3=A9nez=20S=C3=A1nchez?= Date: Fri, 19 Dec 2025 15:11:35 +0100 Subject: [PATCH 048/229] Provisioning: fix multi-tenant and single-tenant authorization (#115435) * feat(auth): add ExtraAudience option to RoundTripper Add ExtraAudience option to RoundTripper to allow operators to include additional audiences (e.g., provisioning group) when connecting to the multitenant aggregator. This ensures tokens include both the target API server's audience and the provisioning group audience, which is required to pass the enforceManagerProperties check. - Add ExtraAudience RoundTripperOption - Improve documentation and comments - Add comprehensive test coverage * fix(operators): add ExtraAudience for dashboards/folders API servers Operators connecting to dashboards and folders API servers need to include the provisioning group audience in addition to the target API server's audience to pass the enforceManagerProperties check. * provisioning: fix settings/stats authorization for AccessPolicy identities The settings and stats endpoints were returning 403 for users accessing via ST->MT because the AccessPolicy identity was routed to the access checker, which doesn't know about these resources. This fix handles 'settings' and 'stats' resources before the access checker path, routing them to the role-based authorization that allows: - settings: Viewer role (read-only, needed by frontend) - stats: Admin role (can leak information) * fix: update BootstrapStep component to remove legacy storage handling and adjust resource counting logic - Removed legacy storage flag from useResourceStats hook in BootstrapStep. - Updated BootstrapStepResourceCounting to simplify rendering logic and removed target prop. - Adjusted tests to reflect changes in resource counting and rendering behavior. * Revert "fix: update BootstrapStep component to remove legacy storage handling and adjust resource counting logic" This reverts commit 148802cbb59722d48347f09ff7351dc747f272a6. * provisioning: allow any authenticated user for settings/stats endpoints These are read-only endpoints needed by the frontend: - settings: returns available repository types and configuration for the wizard - stats: returns resource counts Authentication is verified before reaching authorization, so any user who reaches these endpoints is already authenticated. Requiring specific org roles failed for AccessPolicy tokens which don't carry traditional roles. * provisioning: remove redundant admin role check from listFolderFiles The admin role check in listFolderFiles was redundant (route-level auth already handles access) and broken for AccessPolicy identities which don't have org roles. File access is controlled by the AccessClient as documented in the route-level authorization comment. * provisioning: add isAdminOrAccessPolicy helper for auth checks Consolidates authorization logic for provisioning endpoints: - Adds isAdminOrAccessPolicy() helper that allows admin users OR AccessPolicy identities - AccessPolicy identities (ST->MT flow) are trusted internal callers without org roles - Regular users must have admin role (matching frontend navtree restriction) Used in: authorizeSettings, authorizeStats, authorizeJobs, listFolderFiles * provisioning: consolidate auth helpers into allowForAdminsOrAccessPolicy Simplifies authorization by: - Adding isAccessPolicy() helper for AccessPolicy identity check - Adding allowForAdminsOrAccessPolicy() that returns Decision directly - Consolidating stats/settings/jobs into single switch case - Using consistent pattern in files.go * provisioning: require admin for files subresource at route level Aligns route-level authorization with handler-level check in listFolderFiles. Both now require admin role OR AccessPolicy identity for consistency. * provisioning: restructure authorization with role-based helpers Reorganizes authorization code for clarity: Role-based helpers (all support AccessPolicy for ST->MT flow): - allowForAdminsOrAccessPolicy: admin role required - allowForEditorsOrAccessPolicy: editor role required - allowForViewersOrAccessPolicy: viewer role required Repository subresources by role: - Admin: repository CRUD, test, files - Editor: jobs, resources, sync, history - Viewer: refs, status (GET only) Connection subresources by role: - Admin: connection CRUD - Viewer: status (GET only) * provisioning: move refs to admin-only refs subresource now requires admin role (or AccessPolicy). Updated documentation comments to reflect current permissions. * provisioning: add fine-grained permissions for connections Adds connection permissions following the same pattern as repositories: - provisioning.connections:create - provisioning.connections:read - provisioning.connections:write - provisioning.connections:delete Roles: - fixed:provisioning.connections:reader (granted to Admin) - fixed:provisioning.connections:writer (granted to Admin) * provisioning: remove non-existent sync subresource from auth The sync subresource doesn't exist - syncing is done via the jobs endpoint. Removed dead code from authorization switch case. * provisioning: use access checker for fine-grained permissions Refactors authorization to use b.access.Check() with verb-based checks: Repository subresources: - CRUD: uses actual verb (get/create/update/delete) - test: uses 'update' (write permission) - files/refs/resources/history/status: uses 'get' (read permission) - jobs: uses actual verb for jobs resource Connection subresources: - CRUD: uses actual verb - status: uses 'get' (read permission) The access checker maps verbs to actions defined in accesscontrol.go. Falls back to admin role for backwards compatibility. Also removes redundant admin check from listFolderFiles since authorization is now properly handled at route level. * provisioning: use verb constants instead of string literals Uses apiutils.VerbGet, apiutils.VerbUpdate instead of "get", "update". * provisioning: use access checker for jobs and historicjobs resources Jobs resource: uses actual verb (create/read/write/delete) HistoricJobs resource: read-only (historicjobs:read) * provisioning: allow viewers to access settings endpoint Settings is read-only and needed by multiple UI pages (not just admin pages). Stats remains admin-only. * provisioning: consolidate role-based resource authorization Extract isRoleBasedResource() and authorizeRoleBasedResource() helpers to avoid duplicating settings/stats resource checks in multiple places. * provisioning: use resource name constants instead of hardcoded strings Replace 'repositories', 'connections', 'jobs', 'historicjobs' with their corresponding ResourceInfo.GetName() constants. * provisioning: delegate file authorization to connector Route level: allow any authenticated user for files subresource Connector: check repositories:read only for directory listing Individual file CRUD: handled by DualReadWriter based on actual resource * provisioning: enhance authorization for files and jobs resources Updated file authorization to fall back to admin role for listing files. Introduced checkAccessForJobs function to manage job permissions, allowing editors to create and manage jobs while maintaining admin-only access for historic jobs. Improved error messaging for permission denials. * provisioning: refactor authorization with fine-grained permissions Authorization changes: - Use access checker with role-based fallback for backwards compatibility - Repositories/Connections: admin role fallback - Jobs: editor role fallback (editors can manage jobs) - HistoricJobs: admin role fallback (read-only) - Settings: viewer role (needed by multiple UI pages) - Stats: admin role Files subresource: - Route level allows any authenticated user - Directory listing checks repositories:read in connector - Individual file CRUD delegated to DualReadWriter Refactored checkAccessWithFallback to accept fallback role parameter. * provisioning: refactor access checker integration for improved authorization Updated the authorization logic to utilize the new access checker across various resources, including files and jobs. This change simplifies the permission checks by removing redundant identity retrieval and enhances error handling. The access checker now supports role-based fallbacks for admin and editor roles, ensuring backward compatibility while streamlining the authorization process for repository and connection subresources. * provisioning: remove legacy access checker tests and refactor access checker implementation Deleted the access_checker_test.go file to streamline the codebase and focus on the updated access checker implementation. Refactored the access checker to enhance clarity and maintainability, ensuring it supports role-based fallback behavior. Updated the access checker integration in the API builder to utilize the new fallback role configuration, improving authorization logic across resources. * refactor: split AccessChecker into TokenAccessChecker and SessionAccessChecker - Renamed NewMultiTenantAccessChecker -> NewTokenAccessChecker (uses AuthInfoFrom) - Renamed NewSingleTenantAccessChecker -> NewSessionAccessChecker (uses GetRequester) - Split into separate files with their own tests - Added mockery-generated mock for AccessChecker interface - Names now reflect identity source rather than deployment mode * fix: correct error message case and use accessWithAdmin for filesConnector - Fixed error message to use lowercase 'admin role is required' - Fixed filesConnector to use accessWithAdmin for proper role fallback - Formatted code * refactor: reduce cyclomatic complexity in filesConnector.Connect Split the Connect handler into smaller focused functions: - handleRequest: main request processing - createDualReadWriter: setup dependencies - parseRequestOptions: extract request options - handleDirectoryListing: GET directory requests - handleMethodRequest: route to method handlers - handleGet/handlePost/handlePut/handleDelete: method-specific logic - handleMove: move operation logic * security: remove blind TypeAccessPolicy bypass from access checkers Removed the code that bypassed authorization for TypeAccessPolicy identities. All identities now go through proper permission verification via the inner access checker, which will validate permissions from ServiceIdentityClaims. This addresses the security concern where TypeAccessPolicy was being trusted blindly without verifying whether the identity came from the wire or in-process. * feat: allow editors to access repository refs subresource Change refs authorization from admin to editor fallback so editors can view repository branches when pushing changes to dashboards/folders. - Split refs from other read-only subresources (resources, history, status) - refs now uses accessWithEditor instead of accessWithAdmin - Updated documentation comment to reflect authorization levels - Added integration test TestIntegrationProvisioning_RefsPermissions verifying editor access and viewer denial * tests: add authorization tests for missing provisioning API endpoints Add comprehensive authorization tests for: - Repository subresources (test, resources, history, status) - Connection status subresource - HistoricJobs resource - Settings and Stats resources All authorization paths are now covered by integration tests. * test: fix RefsPermissions test to use GitHub repository Use github-readonly.json.tmpl template instead of local folder, since refs endpoint requires a versioned repository that supports git operations. * chore: format test files * fix: make settings/stats authorization work in MT mode Update authorizeRoleBasedResource to check authlib.AuthInfoFrom(ctx) for AccessPolicy identity type in addition to identity.GetRequester(ctx). This ensures AccessPolicy identities are recognized in MT mode where identity.GetRequester may not set the identity type correctly. * fix: remove unused authorization helper functions Remove allowForAdminsOrAccessPolicy and allowForViewersOrAccessPolicy as they are no longer used after refactoring to use authorizeRoleBasedResource. * Fix AccessPolicy identity detection in ST authorizer - Add check for AccessPolicy identities via GetAuthID() in authorizeRoleBasedResource - Extended JWT may set identity type to TypeUser but AuthID is 'access-policy:...' - Forward user ID token in X-Grafana-Id header in RoundTripper for aggregator forwarding * Revert "Fix AccessPolicy identity detection in ST authorizer" This reverts commit 0f4885e503a633d5e78252842d8885d396ad1bab. * Add fine-grained permissions for settings and stats endpoints - Add provisioning.settings:read action (granted to Viewer role) - Add provisioning.stats:read action (granted to Admin role) - Add accessWithViewer to APIBuilder for Viewer role fallback - Use access checker for settings/stats authorization - Remove role-based authorization functions (isRoleBasedResource, authorizeRoleBasedResource) This makes settings and stats consistent with other provisioning resources and works properly in both ST and MT modes via the access checker. * Remove AUTHORIZATION_COVERAGE.md * Add provisioning resources to RBAC mapper - Add connections, settings, stats to provisioning.grafana.app mappings - Required for authz service to translate K8s verbs to legacy actions - Fixes 403 errors for settings/stats in MT mode * refactor: merge access checkers with original fallthrough behavior Merge tokenAccessChecker and sessionAccessChecker into a unified access checker that implements the original fallthrough behavior: 1. First try to get identity from access token (authlib.AuthInfoFrom) 2. If token exists AND (is TypeAccessPolicy OR useExclusivelyAccessCheckerForAuthz), use the access checker with token identity 3. If no token or conditions not met, fall back to session identity (identity.GetRequester) with optional role-based fallback This fixes the issue where settings/stats/connections endpoints were failing in MT mode because the tokenAccessChecker was returning an error when there was no auth info in context, instead of falling through to session-based authorization. The unified checker now properly handles: - MT mode: tries token first, falls back to session if no token - ST mode: only uses token for AccessPolicy identities, otherwise session - Role fallback: applies when configured and access checker denies * Revert "refactor: merge access checkers with original fallthrough behavior" This reverts commit 96451f948bf8d723845567d2a26e7ecffda483ba. * Grant settings view role to all * fix: use actual request verb for settings/stats authorization Use a.GetVerb() instead of hardcoded VerbGet for settings and stats authorization. When listing resources (hitting collection endpoint), the verb is 'list' not 'get', and this mismatch could cause issues with the RBAC service. * debug: add logging to access checkers for authorization debugging Add klog debug logs (V4 level) to token and session access checkers to help diagnose why settings/stats authorization is failing while connections works. * debug: improve access checker logging with grafana-app-sdk logger - Use grafana-app-sdk logging.FromContext instead of klog - Add error wrapping with resource.group format for better context - Log more details including folder, group, and allowed status - Log error.Error() for better error message visibility * chore: use generic log messages in access checkers * Revert "Grant settings view role to all" This reverts commit 3f5758cf3656b7e6c267e7a3e83d98da1949ab20. * fix: use request verb for historicjobs authorization The original role-based check allowed any verb for admins. To preserve this behavior with the access checker, we should pass the actual verb from the request instead of hardcoding VerbGet. --------- Co-authored-by: Charandas Batra --- apps/provisioning/pkg/auth/access_checker.go | 22 + .../pkg/auth/access_checker_mock.go | 135 ++++++ apps/provisioning/pkg/auth/round_tripper.go | 60 ++- .../pkg/auth/round_tripper_test.go | 23 +- .../pkg/auth/session_access_checker.go | 153 +++++++ .../pkg/auth/session_access_checker_test.go | 244 +++++++++++ .../pkg/auth/token_access_checker.go | 92 +++++ .../pkg/auth/token_access_checker_test.go | 137 ++++++ pkg/operators/provisioning/config.go | 2 +- .../apis/provisioning/accesscontrol.go | 88 ++++ pkg/registry/apis/provisioning/files.go | 391 ++++++++++-------- pkg/registry/apis/provisioning/register.go | 336 ++++++++------- .../apis/provisioning/resources/dualwriter.go | 49 +-- pkg/services/authz/rbac/mapper.go | 3 + .../connection_status_auth_test.go | 88 ++++ .../provisioning/historicjobs_auth_test.go | 95 +++++ .../repository_subresources_auth_test.go | 236 +++++++++++ .../apis/provisioning/repository_test.go | 63 +++ .../provisioning/settings_stats_auth_test.go | 104 +++++ 19 files changed, 1955 insertions(+), 366 deletions(-) create mode 100644 apps/provisioning/pkg/auth/access_checker.go create mode 100644 apps/provisioning/pkg/auth/access_checker_mock.go create mode 100644 apps/provisioning/pkg/auth/session_access_checker.go create mode 100644 apps/provisioning/pkg/auth/session_access_checker_test.go create mode 100644 apps/provisioning/pkg/auth/token_access_checker.go create mode 100644 apps/provisioning/pkg/auth/token_access_checker_test.go create mode 100644 pkg/tests/apis/provisioning/connection_status_auth_test.go create mode 100644 pkg/tests/apis/provisioning/historicjobs_auth_test.go create mode 100644 pkg/tests/apis/provisioning/repository_subresources_auth_test.go create mode 100644 pkg/tests/apis/provisioning/settings_stats_auth_test.go diff --git a/apps/provisioning/pkg/auth/access_checker.go b/apps/provisioning/pkg/auth/access_checker.go new file mode 100644 index 00000000000..841c5421f2a --- /dev/null +++ b/apps/provisioning/pkg/auth/access_checker.go @@ -0,0 +1,22 @@ +package auth + +import ( + "context" + + authlib "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/apimachinery/identity" +) + +//go:generate mockery --name AccessChecker --structname MockAccessChecker --inpackage --filename access_checker_mock.go --with-expecter + +// AccessChecker provides access control checks with optional role-based fallback. +type AccessChecker interface { + // Check performs an access check and returns nil if allowed, or an appropriate + // API error if denied. If req.Namespace is empty, it will be filled from the + // identity's namespace. + Check(ctx context.Context, req authlib.CheckRequest, folder string) error + + // WithFallbackRole returns an AccessChecker configured with the specified fallback role. + // Whether the fallback is actually applied depends on the implementation. + WithFallbackRole(role identity.RoleType) AccessChecker +} diff --git a/apps/provisioning/pkg/auth/access_checker_mock.go b/apps/provisioning/pkg/auth/access_checker_mock.go new file mode 100644 index 00000000000..d0f1cddd7fc --- /dev/null +++ b/apps/provisioning/pkg/auth/access_checker_mock.go @@ -0,0 +1,135 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +package auth + +import ( + context "context" + + identity "github.com/grafana/grafana/pkg/apimachinery/identity" + mock "github.com/stretchr/testify/mock" + + types "github.com/grafana/authlib/types" +) + +// MockAccessChecker is an autogenerated mock type for the AccessChecker type +type MockAccessChecker struct { + mock.Mock +} + +type MockAccessChecker_Expecter struct { + mock *mock.Mock +} + +func (_m *MockAccessChecker) EXPECT() *MockAccessChecker_Expecter { + return &MockAccessChecker_Expecter{mock: &_m.Mock} +} + +// Check provides a mock function with given fields: ctx, req, folder +func (_m *MockAccessChecker) Check(ctx context.Context, req types.CheckRequest, folder string) error { + ret := _m.Called(ctx, req, folder) + + if len(ret) == 0 { + panic("no return value specified for Check") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, types.CheckRequest, string) error); ok { + r0 = rf(ctx, req, folder) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockAccessChecker_Check_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Check' +type MockAccessChecker_Check_Call struct { + *mock.Call +} + +// Check is a helper method to define mock.On call +// - ctx context.Context +// - req types.CheckRequest +// - folder string +func (_e *MockAccessChecker_Expecter) Check(ctx interface{}, req interface{}, folder interface{}) *MockAccessChecker_Check_Call { + return &MockAccessChecker_Check_Call{Call: _e.mock.On("Check", ctx, req, folder)} +} + +func (_c *MockAccessChecker_Check_Call) Run(run func(ctx context.Context, req types.CheckRequest, folder string)) *MockAccessChecker_Check_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(types.CheckRequest), args[2].(string)) + }) + return _c +} + +func (_c *MockAccessChecker_Check_Call) Return(_a0 error) *MockAccessChecker_Check_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockAccessChecker_Check_Call) RunAndReturn(run func(context.Context, types.CheckRequest, string) error) *MockAccessChecker_Check_Call { + _c.Call.Return(run) + return _c +} + +// WithFallbackRole provides a mock function with given fields: role +func (_m *MockAccessChecker) WithFallbackRole(role identity.RoleType) AccessChecker { + ret := _m.Called(role) + + if len(ret) == 0 { + panic("no return value specified for WithFallbackRole") + } + + var r0 AccessChecker + if rf, ok := ret.Get(0).(func(identity.RoleType) AccessChecker); ok { + r0 = rf(role) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(AccessChecker) + } + } + + return r0 +} + +// MockAccessChecker_WithFallbackRole_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WithFallbackRole' +type MockAccessChecker_WithFallbackRole_Call struct { + *mock.Call +} + +// WithFallbackRole is a helper method to define mock.On call +// - role identity.RoleType +func (_e *MockAccessChecker_Expecter) WithFallbackRole(role interface{}) *MockAccessChecker_WithFallbackRole_Call { + return &MockAccessChecker_WithFallbackRole_Call{Call: _e.mock.On("WithFallbackRole", role)} +} + +func (_c *MockAccessChecker_WithFallbackRole_Call) Run(run func(role identity.RoleType)) *MockAccessChecker_WithFallbackRole_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(identity.RoleType)) + }) + return _c +} + +func (_c *MockAccessChecker_WithFallbackRole_Call) Return(_a0 AccessChecker) *MockAccessChecker_WithFallbackRole_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockAccessChecker_WithFallbackRole_Call) RunAndReturn(run func(identity.RoleType) AccessChecker) *MockAccessChecker_WithFallbackRole_Call { + _c.Call.Return(run) + return _c +} + +// NewMockAccessChecker creates a new instance of MockAccessChecker. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockAccessChecker(t interface { + mock.TestingT + Cleanup(func()) +}) *MockAccessChecker { + mock := &MockAccessChecker{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/apps/provisioning/pkg/auth/round_tripper.go b/apps/provisioning/pkg/auth/round_tripper.go index 0d2f1cb4ac4..f5da0d778f0 100644 --- a/apps/provisioning/pkg/auth/round_tripper.go +++ b/apps/provisioning/pkg/auth/round_tripper.go @@ -1,3 +1,4 @@ +// Package auth provides authentication utilities for the provisioning API. package auth import ( @@ -6,7 +7,6 @@ import ( "net/http" "github.com/grafana/authlib/authn" - "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" utilnet "k8s.io/apimachinery/pkg/util/net" ) @@ -15,29 +15,61 @@ type tokenExchanger interface { Exchange(ctx context.Context, req authn.TokenExchangeRequest) (*authn.TokenExchangeResponse, error) } -// RoundTripper injects an exchanged access token for the provisioning API into outgoing requests. -type RoundTripper struct { - client tokenExchanger - transport http.RoundTripper - audience string +// RoundTripperOption configures optional behavior for the RoundTripper. +type RoundTripperOption func(*RoundTripper) + +// ExtraAudience appends an additional audience to the token exchange request. +// +// This is primarily used by operators connecting to the multitenant aggregator, +// where the token must include both the target API server's audience (e.g., dashboards, +// folders) and the provisioning group audience. The provisioning group audience is +// required so that the token passes the enforceManagerProperties check, which prevents +// unauthorized updates to provisioned resources. +// +// Example: +// +// authrt.NewRoundTripper(client, rt, "dashboards.grafana.app", authrt.ExtraAudience("provisioning.grafana.app")) +func ExtraAudience(audience string) RoundTripperOption { + return func(rt *RoundTripper) { + rt.extraAudience = audience + } } -// NewRoundTripper constructs a RoundTripper that exchanges the provided token per request -// and forwards the request to the provided base transport. -func NewRoundTripper(tokenExchangeClient tokenExchanger, base http.RoundTripper, audience string) *RoundTripper { - return &RoundTripper{ +// RoundTripper is an http.RoundTripper that performs token exchange before each request. +// It exchanges the service's credentials for an access token scoped to the configured +// audience(s), then injects that token into the outgoing request's X-Access-Token header. +type RoundTripper struct { + client tokenExchanger + transport http.RoundTripper + audience string + extraAudience string +} + +// NewRoundTripper creates a RoundTripper that exchanges tokens for each outgoing request. +// +// Parameters: +// - tokenExchangeClient: the client used to exchange credentials for access tokens +// - base: the underlying transport to delegate requests to after token injection +// - audience: the primary audience for the token (typically the target API server's group) +// - opts: optional configuration (e.g., ExtraAudience to include additional audiences) +func NewRoundTripper(tokenExchangeClient tokenExchanger, base http.RoundTripper, audience string, opts ...RoundTripperOption) *RoundTripper { + rt := &RoundTripper{ client: tokenExchangeClient, transport: base, audience: audience, } + for _, opt := range opts { + opt(rt) + } + return rt } +// RoundTrip exchanges credentials for an access token and injects it into the request. +// The token is scoped to all configured audiences and the wildcard namespace ("*"). func (t *RoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - // when we want to write resources with the provisioning API, the audience needs to include provisioning - // so that it passes the check in enforceManagerProperties, which prevents others from updating provisioned resources audiences := []string{t.audience} - if t.audience != v0alpha1.GROUP { - audiences = append(audiences, v0alpha1.GROUP) + if t.extraAudience != "" && t.extraAudience != t.audience { + audiences = append(audiences, t.extraAudience) } tokenResponse, err := t.client.Exchange(req.Context(), authn.TokenExchangeRequest{ diff --git a/apps/provisioning/pkg/auth/round_tripper_test.go b/apps/provisioning/pkg/auth/round_tripper_test.go index e3ae4b7b3d4..c1b2b81e17f 100644 --- a/apps/provisioning/pkg/auth/round_tripper_test.go +++ b/apps/provisioning/pkg/auth/round_tripper_test.go @@ -71,16 +71,29 @@ func TestRoundTripper_AudiencesAndNamespace(t *testing.T) { tests := []struct { name string audience string + extraAudience string wantAudiences []string }{ { - name: "adds group when custom audience", + name: "uses only provided audience by default", audience: "example-audience", + wantAudiences: []string{"example-audience"}, + }, + { + name: "uses only group audience by default", + audience: v0alpha1.GROUP, + wantAudiences: []string{v0alpha1.GROUP}, + }, + { + name: "extra audience adds provisioning group", + audience: "example-audience", + extraAudience: v0alpha1.GROUP, wantAudiences: []string{"example-audience", v0alpha1.GROUP}, }, { - name: "no duplicate when group audience", + name: "extra audience no duplicate when same as primary", audience: v0alpha1.GROUP, + extraAudience: v0alpha1.GROUP, wantAudiences: []string{v0alpha1.GROUP}, }, } @@ -88,11 +101,15 @@ func TestRoundTripper_AudiencesAndNamespace(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { fx := &fakeExchanger{resp: &authn.TokenExchangeResponse{Token: "abc123"}} + var opts []RoundTripperOption + if tt.extraAudience != "" { + opts = append(opts, ExtraAudience(tt.extraAudience)) + } tr := NewRoundTripper(fx, roundTripperFunc(func(_ *http.Request) (*http.Response, error) { rr := httptest.NewRecorder() rr.WriteHeader(http.StatusOK) return rr.Result(), nil - }), tt.audience) + }), tt.audience, opts...) req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example", nil) resp, err := tr.RoundTrip(req) diff --git a/apps/provisioning/pkg/auth/session_access_checker.go b/apps/provisioning/pkg/auth/session_access_checker.go new file mode 100644 index 00000000000..1bc6a1b7218 --- /dev/null +++ b/apps/provisioning/pkg/auth/session_access_checker.go @@ -0,0 +1,153 @@ +package auth + +import ( + "context" + "fmt" + "strings" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" + + authlib "github.com/grafana/authlib/types" + "github.com/grafana/grafana-app-sdk/logging" + "github.com/grafana/grafana/pkg/apimachinery/identity" +) + +// sessionAccessChecker implements AccessChecker using Grafana session identity. +type sessionAccessChecker struct { + inner authlib.AccessChecker + fallbackRole identity.RoleType +} + +// NewSessionAccessChecker creates an AccessChecker that gets identity from Grafana +// sessions via GetRequester(ctx). Supports optional role-based fallback via +// WithFallbackRole for backwards compatibility. +func NewSessionAccessChecker(inner authlib.AccessChecker) AccessChecker { + return &sessionAccessChecker{ + inner: inner, + fallbackRole: "", + } +} + +// WithFallbackRole returns a new AccessChecker with the specified fallback role. +func (c *sessionAccessChecker) WithFallbackRole(role identity.RoleType) AccessChecker { + return &sessionAccessChecker{ + inner: c.inner, + fallbackRole: role, + } +} + +// Check performs an access check with optional role-based fallback. +// Returns nil if access is allowed, or an appropriate API error if denied. +func (c *sessionAccessChecker) Check(ctx context.Context, req authlib.CheckRequest, folder string) error { + logger := logging.FromContext(ctx).With("logger", "sessionAccessChecker") + + // Get identity from Grafana session + requester, err := identity.GetRequester(ctx) + if err != nil { + logger.Debug("failed to get requester", + "resource", req.Resource, + "verb", req.Verb, + "error", err.Error(), + ) + return apierrors.NewUnauthorized(fmt.Sprintf("failed to get requester: %v", err)) + } + + logger.Debug("checking access", + "identityType", requester.GetIdentityType(), + "orgRole", requester.GetOrgRole(), + "namespace", requester.GetNamespace(), + "resource", req.Resource, + "verb", req.Verb, + "group", req.Group, + "name", req.Name, + "folder", folder, + "fallbackRole", c.fallbackRole, + ) + + // Fill in namespace from identity if not provided + if req.Namespace == "" { + req.Namespace = requester.GetNamespace() + } + + // Perform the access check + rsp, err := c.inner.Check(ctx, requester, req, folder) + + // Build the GroupResource for error messages + gr := schema.GroupResource{Group: req.Group, Resource: req.Resource} + + // No fallback configured, return result directly + if c.fallbackRole == "" { + if err != nil { + logger.Debug("access check error (no fallback)", + "resource", req.Resource, + "verb", req.Verb, + "error", err.Error(), + ) + return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("%s.%s is forbidden: %w", req.Resource, req.Group, err)) + } + if !rsp.Allowed { + logger.Debug("access check denied (no fallback)", + "resource", req.Resource, + "verb", req.Verb, + "group", req.Group, + "allowed", rsp.Allowed, + ) + return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("permission denied")) + } + logger.Debug("access allowed", + "resource", req.Resource, + "verb", req.Verb, + ) + return nil + } + + // Fallback is configured - apply fallback logic + if err != nil { + if requester.GetOrgRole().Includes(c.fallbackRole) { + logger.Debug("access allowed via role fallback (after error)", + "resource", req.Resource, + "verb", req.Verb, + "fallbackRole", c.fallbackRole, + "orgRole", requester.GetOrgRole(), + ) + return nil // Fallback succeeded + } + logger.Debug("access check error (fallback failed)", + "resource", req.Resource, + "verb", req.Verb, + "error", err.Error(), + "fallbackRole", c.fallbackRole, + "orgRole", requester.GetOrgRole(), + ) + return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("%s.%s is forbidden: %w", req.Resource, req.Group, err)) + } + + if rsp.Allowed { + logger.Debug("access allowed", + "resource", req.Resource, + "verb", req.Verb, + ) + return nil + } + + // Fall back to role for backwards compatibility + if requester.GetOrgRole().Includes(c.fallbackRole) { + logger.Debug("access allowed via role fallback", + "resource", req.Resource, + "verb", req.Verb, + "fallbackRole", c.fallbackRole, + "orgRole", requester.GetOrgRole(), + ) + return nil // Fallback succeeded + } + + logger.Debug("access denied (fallback role not met)", + "resource", req.Resource, + "verb", req.Verb, + "group", req.Group, + "fallbackRole", c.fallbackRole, + "orgRole", requester.GetOrgRole(), + ) + return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("%s role is required", strings.ToLower(string(c.fallbackRole)))) +} diff --git a/apps/provisioning/pkg/auth/session_access_checker_test.go b/apps/provisioning/pkg/auth/session_access_checker_test.go new file mode 100644 index 00000000000..1e99a6e46db --- /dev/null +++ b/apps/provisioning/pkg/auth/session_access_checker_test.go @@ -0,0 +1,244 @@ +package auth + +import ( + "context" + "errors" + "testing" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + + authlib "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/user" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockRequester implements identity.Requester for testing. +type mockRequester struct { + identity.Requester + orgRole identity.RoleType + identityType authlib.IdentityType + namespace string +} + +func (m *mockRequester) GetOrgRole() identity.RoleType { + return m.orgRole +} + +func (m *mockRequester) GetIdentityType() authlib.IdentityType { + return m.identityType +} + +func (m *mockRequester) GetNamespace() string { + return m.namespace +} + +func TestSessionAccessChecker_Check(t *testing.T) { + ctx := context.Background() + req := authlib.CheckRequest{ + Verb: "get", + Group: "provisioning.grafana.app", + Resource: "repositories", + Name: "test-repo", + Namespace: "default", + } + + tests := []struct { + name string + fallbackRole identity.RoleType + innerResponse authlib.CheckResponse + innerErr error + requester *mockRequester + expectAllow bool + }{ + { + name: "allowed by checker", + fallbackRole: identity.RoleAdmin, + innerResponse: authlib.CheckResponse{Allowed: true}, + requester: &mockRequester{orgRole: identity.RoleViewer, identityType: authlib.TypeUser}, + expectAllow: true, + }, + { + name: "denied by checker, fallback to admin role succeeds", + fallbackRole: identity.RoleAdmin, + innerResponse: authlib.CheckResponse{Allowed: false}, + requester: &mockRequester{orgRole: identity.RoleAdmin, identityType: authlib.TypeUser}, + expectAllow: true, + }, + { + name: "denied by checker, fallback to admin role fails for viewer", + fallbackRole: identity.RoleAdmin, + innerResponse: authlib.CheckResponse{Allowed: false}, + requester: &mockRequester{orgRole: identity.RoleViewer, identityType: authlib.TypeUser}, + expectAllow: false, + }, + { + name: "error from checker, fallback to admin role succeeds", + fallbackRole: identity.RoleAdmin, + innerErr: errors.New("access check failed"), + requester: &mockRequester{orgRole: identity.RoleAdmin, identityType: authlib.TypeUser}, + expectAllow: true, + }, + { + name: "error from checker, fallback fails for viewer", + fallbackRole: identity.RoleAdmin, + innerErr: errors.New("access check failed"), + requester: &mockRequester{orgRole: identity.RoleViewer, identityType: authlib.TypeUser}, + expectAllow: false, + }, + { + name: "denied, editor fallback succeeds for editor", + fallbackRole: identity.RoleEditor, + innerResponse: authlib.CheckResponse{Allowed: false}, + requester: &mockRequester{orgRole: identity.RoleEditor, identityType: authlib.TypeUser}, + expectAllow: true, + }, + { + name: "denied, editor fallback fails for viewer", + fallbackRole: identity.RoleEditor, + innerResponse: authlib.CheckResponse{Allowed: false}, + requester: &mockRequester{orgRole: identity.RoleViewer, identityType: authlib.TypeUser}, + expectAllow: false, + }, + { + name: "no fallback configured, denied stays denied", + fallbackRole: "", // no fallback + innerResponse: authlib.CheckResponse{Allowed: false}, + requester: &mockRequester{orgRole: identity.RoleAdmin, identityType: authlib.TypeUser}, + expectAllow: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &mockInnerAccessChecker{ + response: tt.innerResponse, + err: tt.innerErr, + } + + checker := NewSessionAccessChecker(mock) + if tt.fallbackRole != "" { + checker = checker.WithFallbackRole(tt.fallbackRole) + } + + // Add requester to context + testCtx := identity.WithRequester(ctx, tt.requester) + + err := checker.Check(testCtx, req, "") + + if tt.expectAllow { + require.NoError(t, err) + } else { + require.Error(t, err) + assert.True(t, apierrors.IsForbidden(err), "expected Forbidden error, got: %v", err) + } + }) + } +} + +func TestSessionAccessChecker_NoRequester(t *testing.T) { + mock := &mockInnerAccessChecker{ + response: authlib.CheckResponse{Allowed: true}, + } + + checker := NewSessionAccessChecker(mock) + err := checker.Check(context.Background(), authlib.CheckRequest{}, "") + + require.Error(t, err) + assert.True(t, apierrors.IsUnauthorized(err), "expected Unauthorized error") +} + +func TestSessionAccessChecker_WithFallbackRole_ImmutableOriginal(t *testing.T) { + mock := &mockInnerAccessChecker{ + response: authlib.CheckResponse{Allowed: false}, + } + + original := NewSessionAccessChecker(mock) + withAdmin := original.WithFallbackRole(identity.RoleAdmin) + withEditor := original.WithFallbackRole(identity.RoleEditor) + + ctx := identity.WithRequester(context.Background(), &mockRequester{ + orgRole: identity.RoleEditor, + identityType: authlib.TypeUser, + }) + + req := authlib.CheckRequest{} + + // Original should deny (no fallback) + err := original.Check(ctx, req, "") + require.Error(t, err, "original should deny without fallback") + + // WithAdmin should deny for editor + err = withAdmin.Check(ctx, req, "") + require.Error(t, err, "admin fallback should deny for editor") + + // WithEditor should allow for editor + err = withEditor.Check(ctx, req, "") + require.NoError(t, err, "editor fallback should allow for editor") +} + +func TestSessionAccessChecker_WithFallbackRole_ChainedCalls(t *testing.T) { + mock := &mockInnerAccessChecker{ + response: authlib.CheckResponse{Allowed: false}, + } + + // Ensure chained WithFallbackRole calls work correctly + checker := NewSessionAccessChecker(mock). + WithFallbackRole(identity.RoleAdmin). + WithFallbackRole(identity.RoleEditor) // This should override admin + + ctx := identity.WithRequester(context.Background(), &mockRequester{ + orgRole: identity.RoleEditor, + identityType: authlib.TypeUser, + }) + + err := checker.Check(ctx, authlib.CheckRequest{}, "") + require.NoError(t, err, "last fallback (editor) should be used") +} + +func TestSessionAccessChecker_RealSignedInUser(t *testing.T) { + mock := &mockInnerAccessChecker{ + response: authlib.CheckResponse{Allowed: false}, + } + + checker := NewSessionAccessChecker(mock).WithFallbackRole(identity.RoleAdmin) + + // Use a real SignedInUser + signedInUser := &user.SignedInUser{ + UserID: 1, + OrgID: 1, + OrgRole: identity.RoleAdmin, + } + + ctx := identity.WithRequester(context.Background(), signedInUser) + + err := checker.Check(ctx, authlib.CheckRequest{}, "") + require.NoError(t, err, "admin user should be allowed via fallback") +} + +func TestSessionAccessChecker_FillsNamespace(t *testing.T) { + mock := &mockInnerAccessChecker{ + response: authlib.CheckResponse{Allowed: true}, + } + + checker := NewSessionAccessChecker(mock) + + ctx := identity.WithRequester(context.Background(), &mockRequester{ + orgRole: identity.RoleAdmin, + identityType: authlib.TypeUser, + namespace: "org-123", + }) + + // Request without namespace + req := authlib.CheckRequest{ + Verb: "get", + Group: "provisioning.grafana.app", + Resource: "repositories", + Name: "test-repo", + // Namespace intentionally empty + } + + err := checker.Check(ctx, req, "") + require.NoError(t, err) +} diff --git a/apps/provisioning/pkg/auth/token_access_checker.go b/apps/provisioning/pkg/auth/token_access_checker.go new file mode 100644 index 00000000000..8df833d7a34 --- /dev/null +++ b/apps/provisioning/pkg/auth/token_access_checker.go @@ -0,0 +1,92 @@ +package auth + +import ( + "context" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" + + authlib "github.com/grafana/authlib/types" + "github.com/grafana/grafana-app-sdk/logging" + "github.com/grafana/grafana/pkg/apimachinery/identity" +) + +// tokenAccessChecker implements AccessChecker using access tokens from context. +type tokenAccessChecker struct { + inner authlib.AccessChecker +} + +// NewTokenAccessChecker creates an AccessChecker that gets identity from access tokens +// via AuthInfoFrom(ctx). Role-based fallback is not supported. +func NewTokenAccessChecker(inner authlib.AccessChecker) AccessChecker { + return &tokenAccessChecker{inner: inner} +} + +// WithFallbackRole returns the same checker since fallback is not supported. +func (c *tokenAccessChecker) WithFallbackRole(_ identity.RoleType) AccessChecker { + return c +} + +// Check performs an access check using AuthInfo from context. +// Returns nil if access is allowed, or an appropriate API error if denied. +func (c *tokenAccessChecker) Check(ctx context.Context, req authlib.CheckRequest, folder string) error { + logger := logging.FromContext(ctx).With("logger", "tokenAccessChecker") + + // Get identity from access token in context + id, ok := authlib.AuthInfoFrom(ctx) + if !ok { + logger.Debug("no auth info in context", + "resource", req.Resource, + "verb", req.Verb, + "namespace", req.Namespace, + ) + return apierrors.NewUnauthorized("no auth info in context") + } + + logger.Debug("checking access", + "identityType", id.GetIdentityType(), + "namespace", id.GetNamespace(), + "resource", req.Resource, + "verb", req.Verb, + "group", req.Group, + "name", req.Name, + "folder", folder, + ) + + // Fill in namespace from identity if not provided + if req.Namespace == "" { + req.Namespace = id.GetNamespace() + } + + // Perform the access check + rsp, err := c.inner.Check(ctx, id, req, folder) + + // Build the GroupResource for error messages + gr := schema.GroupResource{Group: req.Group, Resource: req.Resource} + + if err != nil { + logger.Debug("access check error", + "resource", req.Resource, + "verb", req.Verb, + "error", err.Error(), + ) + return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("%s.%s is forbidden: %w", req.Resource, req.Group, err)) + } + if !rsp.Allowed { + logger.Debug("access check denied", + "resource", req.Resource, + "verb", req.Verb, + "group", req.Group, + "identityType", id.GetIdentityType(), + "allowed", rsp.Allowed, + ) + return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("permission denied")) + } + + logger.Debug("access allowed", + "resource", req.Resource, + "verb", req.Verb, + ) + return nil +} diff --git a/apps/provisioning/pkg/auth/token_access_checker_test.go b/apps/provisioning/pkg/auth/token_access_checker_test.go new file mode 100644 index 00000000000..bce0d3a77a0 --- /dev/null +++ b/apps/provisioning/pkg/auth/token_access_checker_test.go @@ -0,0 +1,137 @@ +package auth + +import ( + "context" + "errors" + "testing" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + + authlib "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTokenAccessChecker_Check(t *testing.T) { + req := authlib.CheckRequest{ + Verb: "get", + Group: "provisioning.grafana.app", + Resource: "repositories", + Name: "test-repo", + Namespace: "default", + } + + tests := []struct { + name string + innerResponse authlib.CheckResponse + innerErr error + authInfo *identity.StaticRequester + expectAllow bool + }{ + { + name: "allowed by checker", + innerResponse: authlib.CheckResponse{Allowed: true}, + authInfo: &identity.StaticRequester{Type: authlib.TypeUser}, + expectAllow: true, + }, + { + name: "denied by checker", + innerResponse: authlib.CheckResponse{Allowed: false}, + authInfo: &identity.StaticRequester{Type: authlib.TypeUser}, + expectAllow: false, + }, + { + name: "error from checker", + innerErr: errors.New("access check failed"), + authInfo: &identity.StaticRequester{Type: authlib.TypeUser}, + expectAllow: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &mockInnerAccessChecker{ + response: tt.innerResponse, + err: tt.innerErr, + } + + checker := NewTokenAccessChecker(mock) + + // Add auth info to context + testCtx := authlib.WithAuthInfo(context.Background(), tt.authInfo) + + err := checker.Check(testCtx, req, "") + + if tt.expectAllow { + require.NoError(t, err) + } else { + require.Error(t, err) + assert.True(t, apierrors.IsForbidden(err), "expected Forbidden error, got: %v", err) + } + }) + } +} + +func TestTokenAccessChecker_NoAuthInfo(t *testing.T) { + mock := &mockInnerAccessChecker{ + response: authlib.CheckResponse{Allowed: true}, + } + + checker := NewTokenAccessChecker(mock) + err := checker.Check(context.Background(), authlib.CheckRequest{}, "") + + require.Error(t, err) + assert.True(t, apierrors.IsUnauthorized(err), "expected Unauthorized error") +} + +func TestTokenAccessChecker_WithFallbackRole_IsNoOp(t *testing.T) { + mock := &mockInnerAccessChecker{ + response: authlib.CheckResponse{Allowed: false}, + } + + checker := NewTokenAccessChecker(mock) + checkerWithFallback := checker.WithFallbackRole(identity.RoleAdmin) + + // They should be the same instance + assert.Same(t, checker, checkerWithFallback, "WithFallbackRole should return same instance") +} + +func TestTokenAccessChecker_FillsNamespace(t *testing.T) { + mock := &mockInnerAccessChecker{ + response: authlib.CheckResponse{Allowed: true}, + } + + checker := NewTokenAccessChecker(mock) + + ctx := authlib.WithAuthInfo(context.Background(), &identity.StaticRequester{ + Type: authlib.TypeUser, + Namespace: "org-123", + }) + + // Request without namespace + req := authlib.CheckRequest{ + Verb: "get", + Group: "provisioning.grafana.app", + Resource: "repositories", + Name: "test-repo", + // Namespace intentionally empty + } + + err := checker.Check(ctx, req, "") + require.NoError(t, err) +} + +// mockInnerAccessChecker implements authlib.AccessChecker for testing. +type mockInnerAccessChecker struct { + response authlib.CheckResponse + err error +} + +func (m *mockInnerAccessChecker) Check(_ context.Context, _ authlib.AuthInfo, _ authlib.CheckRequest, _ string) (authlib.CheckResponse, error) { + return m.response, m.err +} + +func (m *mockInnerAccessChecker) Compile(_ context.Context, _ authlib.AuthInfo, _ authlib.ListRequest) (authlib.ItemChecker, authlib.Zookie, error) { + return nil, nil, nil +} diff --git a/pkg/operators/provisioning/config.go b/pkg/operators/provisioning/config.go index 8e496e5e556..05552e56095 100644 --- a/pkg/operators/provisioning/config.go +++ b/pkg/operators/provisioning/config.go @@ -178,7 +178,7 @@ func setupFromConfig(cfg *setting.Cfg, registry prometheus.Registerer) (controll APIPath: "/apis", Host: url, WrapTransport: transport.WrapperFunc(func(rt http.RoundTripper) http.RoundTripper { - return authrt.NewRoundTripper(tokenExchangeClient, rt, group) + return authrt.NewRoundTripper(tokenExchangeClient, rt, group, authrt.ExtraAudience(provisioning.GROUP)) }), Transport: &http.Transport{ MaxConnsPerHost: 100, diff --git a/pkg/registry/apis/provisioning/accesscontrol.go b/pkg/registry/apis/provisioning/accesscontrol.go index e56ab7b06e2..755eabb60da 100644 --- a/pkg/registry/apis/provisioning/accesscontrol.go +++ b/pkg/registry/apis/provisioning/accesscontrol.go @@ -12,6 +12,12 @@ const ( ActionProvisioningRepositoriesRead = "provisioning.repositories:read" // GET + LIST. ActionProvisioningRepositoriesDelete = "provisioning.repositories:delete" // DELETE. + // Connections + ActionProvisioningConnectionsCreate = "provisioning.connections:create" // CREATE. + ActionProvisioningConnectionsWrite = "provisioning.connections:write" // UPDATE. + ActionProvisioningConnectionsRead = "provisioning.connections:read" // GET + LIST. + ActionProvisioningConnectionsDelete = "provisioning.connections:delete" // DELETE. + // Jobs ActionProvisioningJobsCreate = "provisioning.jobs:create" // CREATE. ActionProvisioningJobsWrite = "provisioning.jobs:write" // UPDATE. @@ -20,6 +26,12 @@ const ( // Historic Jobs ActionProvisioningHistoricJobsRead = "provisioning.historicjobs:read" // GET + LIST. + + // Settings (read-only, needed by multiple UI pages) + ActionProvisioningSettingsRead = "provisioning.settings:read" // GET + LIST. + + // Stats (read-only, admin-only) + ActionProvisioningStatsRead = "provisioning.stats:read" // GET + LIST. ) func registerAccessControlRoles(service accesscontrol.Service) error { @@ -63,6 +75,46 @@ func registerAccessControlRoles(service accesscontrol.Service) error { Grants: []string{string(org.RoleAdmin)}, } + // Connections + connectionsReader := accesscontrol.RoleRegistration{ + Role: accesscontrol.RoleDTO{ + Name: "fixed:provisioning.connections:reader", + DisplayName: "Connections Reader", + Description: "Read and list provisioning connections.", + Group: "Provisioning", + Permissions: []accesscontrol.Permission{ + { + Action: ActionProvisioningConnectionsRead, + }, + }, + }, + Grants: []string{string(org.RoleAdmin)}, + } + + connectionsWriter := accesscontrol.RoleRegistration{ + Role: accesscontrol.RoleDTO{ + Name: "fixed:provisioning.connections:writer", + DisplayName: "Connections Writer", + Description: "Create, update and delete provisioning connections.", + Group: "Provisioning", + Permissions: []accesscontrol.Permission{ + { + Action: ActionProvisioningConnectionsCreate, + }, + { + Action: ActionProvisioningConnectionsRead, + }, + { + Action: ActionProvisioningConnectionsWrite, + }, + { + Action: ActionProvisioningConnectionsDelete, + }, + }, + }, + Grants: []string{string(org.RoleAdmin)}, + } + // Jobs jobsReader := accesscontrol.RoleRegistration{ Role: accesscontrol.RoleDTO{ @@ -119,11 +171,47 @@ func registerAccessControlRoles(service accesscontrol.Service) error { Grants: []string{string(org.RoleAdmin)}, } + // Settings - granted to Viewer (accessible by all logged-in users) + settingsReader := accesscontrol.RoleRegistration{ + Role: accesscontrol.RoleDTO{ + Name: "fixed:provisioning.settings:reader", + DisplayName: "Settings Reader", + Description: "Read provisioning settings.", + Group: "Provisioning", + Permissions: []accesscontrol.Permission{ + { + Action: ActionProvisioningSettingsRead, + }, + }, + }, + Grants: []string{string(org.RoleViewer)}, + } + + // Stats - granted to Admin only + statsReader := accesscontrol.RoleRegistration{ + Role: accesscontrol.RoleDTO{ + Name: "fixed:provisioning.stats:reader", + DisplayName: "Stats Reader", + Description: "Read provisioning stats.", + Group: "Provisioning", + Permissions: []accesscontrol.Permission{ + { + Action: ActionProvisioningStatsRead, + }, + }, + }, + Grants: []string{string(org.RoleAdmin)}, + } + return service.DeclareFixedRoles( repositoriesReader, repositoriesWriter, + connectionsReader, + connectionsWriter, jobsReader, jobsWriter, historicJobsReader, + settingsReader, + statsReader, ) } diff --git a/pkg/registry/apis/provisioning/files.go b/pkg/registry/apis/provisioning/files.go index ad9bc4bc472..a418860caac 100644 --- a/pkg/registry/apis/provisioning/files.go +++ b/pkg/registry/apis/provisioning/files.go @@ -13,9 +13,10 @@ import ( authlib "github.com/grafana/authlib/types" "github.com/grafana/grafana-app-sdk/logging" provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/auth" "github.com/grafana/grafana/apps/provisioning/pkg/repository" "github.com/grafana/grafana/apps/provisioning/pkg/safepath" - "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" ) @@ -26,12 +27,12 @@ const ( type filesConnector struct { getter RepoGetter - access authlib.AccessChecker + access auth.AccessChecker parsers resources.ParserFactory clients resources.ClientFactory } -func NewFilesConnector(getter RepoGetter, parsers resources.ParserFactory, clients resources.ClientFactory, access authlib.AccessChecker) *filesConnector { +func NewFilesConnector(getter RepoGetter, parsers resources.ParserFactory, clients resources.ClientFactory, access auth.AccessChecker) *filesConnector { return &filesConnector{getter: getter, parsers: parsers, clients: clients, access: access} } @@ -74,179 +75,233 @@ func (c *filesConnector) Connect(ctx context.Context, name string, opts runtime. ctx = logging.Context(ctx, logger) return WithTimeout(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - repo, err := c.getRepo(ctx, r.Method, name) - if err != nil { - logger.Debug("failed to find repository", "error", err) - responder.Error(err) - return - } - - readWriter, ok := repo.(repository.ReaderWriter) - if !ok { - responder.Error(apierrors.NewBadRequest("repository does not support read-writing")) - return - } - - parser, err := c.parsers.GetParser(ctx, readWriter) - if err != nil { - responder.Error(fmt.Errorf("failed to get parser: %w", err)) - return - } - - clients, err := c.clients.Clients(ctx, repo.Config().Namespace) - if err != nil { - responder.Error(fmt.Errorf("failed to get clients: %w", err)) - return - } - - folderClient, err := clients.Folder(ctx) - if err != nil { - responder.Error(fmt.Errorf("failed to get folder client: %w", err)) - return - } - folders := resources.NewFolderManager(readWriter, folderClient, resources.NewEmptyFolderTree()) - dualReadWriter := resources.NewDualReadWriter(readWriter, parser, folders, c.access) - query := r.URL.Query() - opts := resources.DualWriteOptions{ - Ref: query.Get("ref"), - Message: query.Get("message"), - SkipDryRun: query.Get("skipDryRun") == "true", - OriginalPath: query.Get("originalPath"), - Branch: repo.Config().Branch(), - } - logger := logger.With("url", r.URL.Path, "ref", opts.Ref, "message", opts.Message) - ctx := logging.Context(r.Context(), logger) - - opts.Path, err = pathAfterPrefix(r.URL.Path, fmt.Sprintf("/%s/files", name)) - if err != nil { - responder.Error(apierrors.NewBadRequest(err.Error())) - return - } - - if err := resources.IsPathSupported(opts.Path); err != nil { - responder.Error(apierrors.NewBadRequest(err.Error())) - return - } - - isDir := safepath.IsDir(opts.Path) - if r.Method == http.MethodGet && isDir { - files, err := c.listFolderFiles(ctx, opts.Path, opts.Ref, readWriter) - if err != nil { - responder.Error(err) - return - } - - responder.Object(http.StatusOK, files) - return - } - - if opts.Path == "" { - responder.Error(apierrors.NewBadRequest("missing request path")) - return - } - - var obj *provisioning.ResourceWrapper - code := http.StatusOK - switch r.Method { - case http.MethodGet: - resource, err := dualReadWriter.Read(ctx, opts.Path, opts.Ref) - if err != nil { - respondWithError(responder, err) - return - } - obj = resource.AsResourceWrapper() - case http.MethodPost: - // Check if this is a move operation first (originalPath query parameter is present) - if opts.OriginalPath != "" { - // For move operations, only read body for file moves (not directory moves) - if !isDir { - opts.Data, err = readBody(r, filesMaxBodySize) - if err != nil { - responder.Error(err) - return - } - } - - resource, err := dualReadWriter.MoveResource(ctx, opts) - if err != nil { - respondWithError(responder, err) - return - } - obj = resource.AsResourceWrapper() - } else if isDir { - obj, err = dualReadWriter.CreateFolder(ctx, opts) - } else { - opts.Data, err = readBody(r, filesMaxBodySize) - if err != nil { - responder.Error(err) - return - } - - var resource *resources.ParsedResource - resource, err = dualReadWriter.CreateResource(ctx, opts) - if err != nil { - respondWithError(responder, err) - return - } - obj = resource.AsResourceWrapper() - } - case http.MethodPut: - // TODO: document in API specification - if isDir { - err = apierrors.NewMethodNotSupported(provisioning.RepositoryResourceInfo.GroupResource(), r.Method) - } else { - opts.Data, err = readBody(r, filesMaxBodySize) - if err != nil { - responder.Error(err) - return - } - - resource, err := dualReadWriter.UpdateResource(ctx, opts) - if err != nil { - respondWithError(responder, err) - return - } - obj = resource.AsResourceWrapper() - } - case http.MethodDelete: - resource, err := dualReadWriter.Delete(ctx, opts) - if err != nil { - respondWithError(responder, err) - return - } - obj = resource.AsResourceWrapper() - default: - err = apierrors.NewMethodNotSupported(provisioning.RepositoryResourceInfo.GroupResource(), r.Method) - } - - if err != nil { - logger.Debug("got an error after processing request", "error", err) - responder.Error(err) - return - } - - if len(obj.Errors) > 0 { - code = http.StatusPartialContent - } - - logger.Debug("request resulted in valid object", "object", obj) - responder.Object(code, obj) + c.handleRequest(ctx, name, r, responder, logger) }), 30*time.Second), nil } -// listFolderFiles returns a list of files in a folder -func (c *filesConnector) listFolderFiles(ctx context.Context, filePath string, ref string, readWriter repository.ReaderWriter) (*provisioning.FileList, error) { - id, err := identity.GetRequester(ctx) +// handleRequest processes the HTTP request for files operations. +func (c *filesConnector) handleRequest(ctx context.Context, name string, r *http.Request, responder rest.Responder, logger logging.Logger) { + repo, err := c.getRepo(ctx, r.Method, name) if err != nil { - return nil, fmt.Errorf("missing auth info in context") + logger.Debug("failed to find repository", "error", err) + responder.Error(err) + return } - // TODO: replace with access check on the repo itself - if !id.GetOrgRole().Includes(identity.RoleAdmin) { - return nil, apierrors.NewForbidden(resources.DashboardResource.GroupResource(), "", - fmt.Errorf("requires admin role")) + readWriter, ok := repo.(repository.ReaderWriter) + if !ok { + responder.Error(apierrors.NewBadRequest("repository does not support read-writing")) + return } + dualReadWriter, err := c.createDualReadWriter(ctx, repo, readWriter) + if err != nil { + responder.Error(err) + return + } + + opts, err := c.parseRequestOptions(r, name, repo) + if err != nil { + responder.Error(apierrors.NewBadRequest(err.Error())) + return + } + + logger = logger.With("url", r.URL.Path, "ref", opts.Ref, "message", opts.Message) + ctx = logging.Context(r.Context(), logger) + + // Handle directory listing separately + isDir := safepath.IsDir(opts.Path) + if r.Method == http.MethodGet && isDir { + c.handleDirectoryListing(ctx, name, opts, readWriter, responder) + return + } + + if opts.Path == "" { + responder.Error(apierrors.NewBadRequest("missing request path")) + return + } + + obj, err := c.handleMethodRequest(ctx, r, opts, isDir, dualReadWriter) + if err != nil { + logger.Debug("got an error after processing request", "error", err) + respondWithError(responder, err) + return + } + + code := http.StatusOK + if len(obj.Errors) > 0 { + code = http.StatusPartialContent + } + + logger.Debug("request resulted in valid object", "object", obj) + responder.Object(code, obj) +} + +// createDualReadWriter sets up the dual read writer with all required dependencies. +func (c *filesConnector) createDualReadWriter(ctx context.Context, repo repository.Repository, readWriter repository.ReaderWriter) (*resources.DualReadWriter, error) { + parser, err := c.parsers.GetParser(ctx, readWriter) + if err != nil { + return nil, fmt.Errorf("failed to get parser: %w", err) + } + + clients, err := c.clients.Clients(ctx, repo.Config().Namespace) + if err != nil { + return nil, fmt.Errorf("failed to get clients: %w", err) + } + + folderClient, err := clients.Folder(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get folder client: %w", err) + } + + folders := resources.NewFolderManager(readWriter, folderClient, resources.NewEmptyFolderTree()) + return resources.NewDualReadWriter(readWriter, parser, folders, c.access), nil +} + +// parseRequestOptions extracts options from the HTTP request. +func (c *filesConnector) parseRequestOptions(r *http.Request, name string, repo repository.Repository) (resources.DualWriteOptions, error) { + query := r.URL.Query() + opts := resources.DualWriteOptions{ + Ref: query.Get("ref"), + Message: query.Get("message"), + SkipDryRun: query.Get("skipDryRun") == "true", + OriginalPath: query.Get("originalPath"), + Branch: repo.Config().Branch(), + } + + path, err := pathAfterPrefix(r.URL.Path, fmt.Sprintf("/%s/files", name)) + if err != nil { + return opts, err + } + opts.Path = path + + if err := resources.IsPathSupported(opts.Path); err != nil { + return opts, err + } + + return opts, nil +} + +// handleDirectoryListing handles GET requests for directory listing. +func (c *filesConnector) handleDirectoryListing(ctx context.Context, name string, opts resources.DualWriteOptions, readWriter repository.ReaderWriter, responder rest.Responder) { + if err := c.authorizeListFiles(ctx, name); err != nil { + responder.Error(err) + return + } + + files, err := c.listFolderFiles(ctx, opts.Path, opts.Ref, readWriter) + if err != nil { + responder.Error(err) + return + } + + responder.Object(http.StatusOK, files) +} + +// handleMethodRequest routes the request to the appropriate handler based on HTTP method. +func (c *filesConnector) handleMethodRequest(ctx context.Context, r *http.Request, opts resources.DualWriteOptions, isDir bool, dualReadWriter *resources.DualReadWriter) (*provisioning.ResourceWrapper, error) { + switch r.Method { + case http.MethodGet: + return c.handleGet(ctx, opts, dualReadWriter) + case http.MethodPost: + return c.handlePost(ctx, r, opts, isDir, dualReadWriter) + case http.MethodPut: + return c.handlePut(ctx, r, opts, isDir, dualReadWriter) + case http.MethodDelete: + return c.handleDelete(ctx, opts, dualReadWriter) + default: + return nil, apierrors.NewMethodNotSupported(provisioning.RepositoryResourceInfo.GroupResource(), r.Method) + } +} + +func (c *filesConnector) handleGet(ctx context.Context, opts resources.DualWriteOptions, dualReadWriter *resources.DualReadWriter) (*provisioning.ResourceWrapper, error) { + resource, err := dualReadWriter.Read(ctx, opts.Path, opts.Ref) + if err != nil { + return nil, err + } + return resource.AsResourceWrapper(), nil +} + +func (c *filesConnector) handlePost(ctx context.Context, r *http.Request, opts resources.DualWriteOptions, isDir bool, dualReadWriter *resources.DualReadWriter) (*provisioning.ResourceWrapper, error) { + // Check if this is a move operation (originalPath query parameter is present) + if opts.OriginalPath != "" { + return c.handleMove(ctx, r, opts, isDir, dualReadWriter) + } + + if isDir { + return dualReadWriter.CreateFolder(ctx, opts) + } + + data, err := readBody(r, filesMaxBodySize) + if err != nil { + return nil, err + } + opts.Data = data + + resource, err := dualReadWriter.CreateResource(ctx, opts) + if err != nil { + return nil, err + } + return resource.AsResourceWrapper(), nil +} + +func (c *filesConnector) handleMove(ctx context.Context, r *http.Request, opts resources.DualWriteOptions, isDir bool, dualReadWriter *resources.DualReadWriter) (*provisioning.ResourceWrapper, error) { + // For move operations, only read body for file moves (not directory moves) + if !isDir { + data, err := readBody(r, filesMaxBodySize) + if err != nil { + return nil, err + } + opts.Data = data + } + + resource, err := dualReadWriter.MoveResource(ctx, opts) + if err != nil { + return nil, err + } + return resource.AsResourceWrapper(), nil +} + +func (c *filesConnector) handlePut(ctx context.Context, r *http.Request, opts resources.DualWriteOptions, isDir bool, dualReadWriter *resources.DualReadWriter) (*provisioning.ResourceWrapper, error) { + if isDir { + return nil, apierrors.NewMethodNotSupported(provisioning.RepositoryResourceInfo.GroupResource(), r.Method) + } + + data, err := readBody(r, filesMaxBodySize) + if err != nil { + return nil, err + } + opts.Data = data + + resource, err := dualReadWriter.UpdateResource(ctx, opts) + if err != nil { + return nil, err + } + return resource.AsResourceWrapper(), nil +} + +func (c *filesConnector) handleDelete(ctx context.Context, opts resources.DualWriteOptions, dualReadWriter *resources.DualReadWriter) (*provisioning.ResourceWrapper, error) { + resource, err := dualReadWriter.Delete(ctx, opts) + if err != nil { + return nil, err + } + return resource.AsResourceWrapper(), nil +} + +// authorizeListFiles checks if the user has repositories:read permission for listing files. +// The access checker handles AccessPolicy identities, namespace resolution, and role-based fallback internally. +func (c *filesConnector) authorizeListFiles(ctx context.Context, repoName string) error { + return c.access.Check(ctx, authlib.CheckRequest{ + Verb: utils.VerbGet, + Group: provisioning.GROUP, + Resource: provisioning.RepositoryResourceInfo.GetName(), + Name: repoName, + }, "") +} + +// listFolderFiles returns a list of files in a folder. +// Authorization is checked via authorizeListFiles before calling this function. +func (c *filesConnector) listFolderFiles(ctx context.Context, filePath string, ref string, readWriter repository.ReaderWriter) (*provisioning.FileList, error) { // TODO: Implement folder navigation if len(filePath) > 0 { return nil, apierrors.NewBadRequest("folder navigation not yet supported") diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index f5376cb20ab..26e97f56b3c 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -29,6 +29,7 @@ import ( "github.com/grafana/grafana-app-sdk/logging" provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/auth" connectionvalidation "github.com/grafana/grafana/apps/provisioning/pkg/connection" appcontroller "github.com/grafana/grafana/apps/provisioning/pkg/controller" clientset "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned" @@ -111,7 +112,10 @@ type APIBuilder struct { unified resource.ResourceClient repoFactory repository.Factory client client.ProvisioningV0alpha1Interface - access authlib.AccessChecker + access auth.AccessChecker + accessWithAdmin auth.AccessChecker + accessWithEditor auth.AccessChecker + accessWithViewer auth.AccessChecker statusPatcher *appcontroller.RepositoryStatusPatcher healthChecker *controller.HealthChecker validator repository.RepositoryValidator @@ -158,6 +162,14 @@ func NewAPIBuilder( parsers := resources.NewParserFactory(clients) resourceLister := resources.NewResourceListerForMigrations(unified) + // Create access checker based on mode + var accessChecker auth.AccessChecker + if useExclusivelyAccessCheckerForAuthz { + accessChecker = auth.NewTokenAccessChecker(access) + } else { + accessChecker = auth.NewSessionAccessChecker(access) + } + b := &APIBuilder{ onlyApiServer: onlyApiServer, tracer: tracer, @@ -170,7 +182,10 @@ func NewAPIBuilder( resourceLister: resourceLister, dashboardAccess: dashboardAccess, unified: unified, - access: access, + access: accessChecker, + accessWithAdmin: accessChecker.WithFallbackRole(identity.RoleAdmin), + accessWithEditor: accessChecker.WithFallbackRole(identity.RoleEditor), + accessWithViewer: accessChecker.WithFallbackRole(identity.RoleViewer), jobHistoryConfig: jobHistoryConfig, extraWorkers: extraWorkers, restConfigGetter: restConfigGetter, @@ -298,161 +313,142 @@ func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer { } } - info, ok := authlib.AuthInfoFrom(ctx) - // when running as standalone API server, the identity type may not always match TypeAccessPolicy - // so we allow it to use the access checker if there is any auth info available - if ok && (authlib.IsIdentityType(info.GetIdentityType(), authlib.TypeAccessPolicy) || b.useExclusivelyAccessCheckerForAuthz) { - res, err := b.access.Check(ctx, info, authlib.CheckRequest{ - Verb: a.GetVerb(), - Group: a.GetAPIGroup(), - Resource: a.GetResource(), - Name: a.GetName(), - Namespace: a.GetNamespace(), - Subresource: a.GetSubresource(), - Path: a.GetPath(), - }, "") - if err != nil { - return authorizer.DecisionDeny, "failed to perform authorization", err - } - - if !res.Allowed { - return authorizer.DecisionDeny, "permission denied", nil - } - - return authorizer.DecisionAllow, "", nil - } - - id, err := identity.GetRequester(ctx) - if err != nil { - return authorizer.DecisionDeny, "failed to find requester", err - } - - return b.authorizeResource(ctx, a, id) + return b.authorizeResource(ctx, a) }) } // authorizeResource handles authorization for different resources. -// Different routes may need different permissions. -// * Reading and modifying a repository's configuration requires administrator privileges. -// * Reading a repository's limited configuration (/stats & /settings) requires viewer privileges. -// * Reading a repository's files requires viewer privileges. -// * Reading a repository's refs requires viewer privileges. -// * Editing a repository's files requires editor privileges. -// * Syncing a repository requires editor privileges. -// * Exporting a repository requires administrator privileges. -// * Migrating a repository requires administrator privileges. -// * Testing a repository configuration requires administrator privileges. -// * Viewing a repository's history requires editor privileges. -func (b *APIBuilder) authorizeResource(ctx context.Context, a authorizer.Attributes, id identity.Requester) (authorizer.Decision, string, error) { +// Uses fine-grained permissions defined in accesscontrol.go: +// +// Repositories: +// - CRUD: repositories:create/read/write/delete +// - Subresources: files (any auth), refs (editor), resources/history/status (admin) +// - Test: repositories:write +// - Jobs subresource: jobs:create/read +// +// Connections: +// - CRUD: connections:create/read/write/delete +// - Status: connections:read +// +// Jobs: +// - CRUD: jobs:create/read/write/delete +// +// Historic Jobs: +// - Read-only: historicjobs:read +// +// Settings: +// - settings:read - granted to Viewer (all logged-in users) +// +// Stats: +// - stats:read - granted to Admin only +func (b *APIBuilder) authorizeResource(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { switch a.GetResource() { case provisioning.RepositoryResourceInfo.GetName(): - return b.authorizeRepositorySubresource(a, id) - case "stats": - return b.authorizeStats(id) - case "settings": - return b.authorizeSettings(id) - case provisioning.JobResourceInfo.GetName(), provisioning.HistoricJobResourceInfo.GetName(): - return b.authorizeJobs(id) + return b.authorizeRepositorySubresource(ctx, a) case provisioning.ConnectionResourceInfo.GetName(): - return b.authorizeConnectionSubresource(a, id) + return b.authorizeConnectionSubresource(ctx, a) + case provisioning.JobResourceInfo.GetName(): + return toAuthorizerDecision(b.accessWithEditor.Check(ctx, authlib.CheckRequest{ + Verb: a.GetVerb(), + Group: provisioning.GROUP, + Resource: provisioning.JobResourceInfo.GetName(), + Name: a.GetName(), + Namespace: a.GetNamespace(), + }, "")) + case provisioning.HistoricJobResourceInfo.GetName(): + // Historic jobs are read-only and admin-only (not editor) + return toAuthorizerDecision(b.accessWithAdmin.Check(ctx, authlib.CheckRequest{ + Verb: a.GetVerb(), + Group: provisioning.GROUP, + Resource: provisioning.HistoricJobResourceInfo.GetName(), + Name: a.GetName(), + Namespace: a.GetNamespace(), + }, "")) + case "settings": + // Settings are read-only and accessible by all logged-in users (Viewer role) + return toAuthorizerDecision(b.accessWithViewer.Check(ctx, authlib.CheckRequest{ + Verb: a.GetVerb(), + Group: provisioning.GROUP, + Resource: "settings", + Namespace: a.GetNamespace(), + }, "")) + case "stats": + // Stats are read-only and admin-only + return toAuthorizerDecision(b.accessWithAdmin.Check(ctx, authlib.CheckRequest{ + Verb: a.GetVerb(), + Group: provisioning.GROUP, + Resource: "stats", + Namespace: a.GetNamespace(), + }, "")) default: - return b.authorizeDefault(id) + return b.authorizeDefault(ctx) } } // authorizeRepositorySubresource handles authorization for repository subresources. -func (b *APIBuilder) authorizeRepositorySubresource(a authorizer.Attributes, id identity.Requester) (authorizer.Decision, string, error) { - // TODO: Support more fine-grained permissions than the basic roles. Especially on Enterprise. - switch a.GetSubresource() { - case "", "test": - // Doing something with the repository itself. - if id.GetOrgRole().Includes(identity.RoleAdmin) { - return authorizer.DecisionAllow, "", nil - } - return authorizer.DecisionDeny, "admin role is required", nil - - case "jobs": - // Posting jobs requires editor privileges (for syncing). - if id.GetOrgRole().Includes(identity.RoleAdmin) || id.GetOrgRole().Includes(identity.RoleEditor) { - return authorizer.DecisionAllow, "", nil - } - return authorizer.DecisionDeny, "editor role is required", nil - - case "refs": - // This is strictly a read operation. It is handy on the frontend for viewers. - if id.GetOrgRole().Includes(identity.RoleViewer) { - return authorizer.DecisionAllow, "", nil - } - return authorizer.DecisionDeny, "viewer role is required", nil - - case "files": - // Access to files is controlled by the AccessClient - return authorizer.DecisionAllow, "", nil - - case "resources", "sync", "history": - // These are strictly read operations. - // Sync can also be somewhat destructive, but it's expected to be fine to import changes. - if id.GetOrgRole().Includes(identity.RoleEditor) { - return authorizer.DecisionAllow, "", nil - } - return authorizer.DecisionDeny, "editor role is required", nil - - case "status": - if id.GetOrgRole().Includes(identity.RoleViewer) && a.GetVerb() == apiutils.VerbGet { - return authorizer.DecisionAllow, "", nil - } - return authorizer.DecisionDeny, "users cannot update the status of a repository", nil - - default: - if id.GetIsGrafanaAdmin() { - return authorizer.DecisionAllow, "", nil - } - return authorizer.DecisionDeny, "unmapped subresource defaults to no access", nil - } -} - -// authorizeStats handles authorization for stats resource. -func (b *APIBuilder) authorizeStats(id identity.Requester) (authorizer.Decision, string, error) { - // This can leak information one shouldn't necessarily have access to. - if id.GetOrgRole().Includes(identity.RoleAdmin) { - return authorizer.DecisionAllow, "", nil - } - return authorizer.DecisionDeny, "admin role is required", nil -} - -// authorizeSettings handles authorization for settings resource. -func (b *APIBuilder) authorizeSettings(id identity.Requester) (authorizer.Decision, string, error) { - // This is strictly a read operation. It is handy on the frontend for viewers. - if id.GetOrgRole().Includes(identity.RoleViewer) { - return authorizer.DecisionAllow, "", nil - } - return authorizer.DecisionDeny, "viewer role is required", nil -} - -// authorizeJobs handles authorization for job resources. -func (b *APIBuilder) authorizeJobs(id identity.Requester) (authorizer.Decision, string, error) { - // Jobs are shown on the configuration page. - if id.GetOrgRole().Includes(identity.RoleAdmin) { - return authorizer.DecisionAllow, "", nil - } - return authorizer.DecisionDeny, "admin role is required", nil -} - -// authorizeRepositorySubresource handles authorization for connections subresources. -func (b *APIBuilder) authorizeConnectionSubresource(a authorizer.Attributes, id identity.Requester) (authorizer.Decision, string, error) { +// Uses the access checker with verb-based authorization. +func (b *APIBuilder) authorizeRepositorySubresource(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { switch a.GetSubresource() { + // Repository CRUD - use access checker with the actual verb case "": - // Doing something with the connection itself. - if id.GetOrgRole().Includes(identity.RoleAdmin) { - return authorizer.DecisionAllow, "", nil - } - return authorizer.DecisionDeny, "admin role is required", nil - case "status": - if id.GetOrgRole().Includes(identity.RoleViewer) && a.GetVerb() == apiutils.VerbGet { - return authorizer.DecisionAllow, "", nil - } - return authorizer.DecisionDeny, "users cannot update the status of a connection", nil + return toAuthorizerDecision(b.accessWithAdmin.Check(ctx, authlib.CheckRequest{ + Verb: a.GetVerb(), + Group: provisioning.GROUP, + Resource: provisioning.RepositoryResourceInfo.GetName(), + Name: a.GetName(), + Namespace: a.GetNamespace(), + }, "")) + + // Test requires write permission (testing before save) + case "test": + return toAuthorizerDecision(b.accessWithAdmin.Check(ctx, authlib.CheckRequest{ + Verb: apiutils.VerbUpdate, + Group: provisioning.GROUP, + Resource: provisioning.RepositoryResourceInfo.GetName(), + Name: a.GetName(), + Namespace: a.GetNamespace(), + }, "")) + + // Files subresource: allow any authenticated user at route level. + // Directory listing checks repositories:read in the connector. + // Individual file operations are authorized by DualReadWriter based on the actual resource. + case "files": + return authorizer.DecisionAllow, "", nil + + // refs subresource - editors need to see branches to push changes + case "refs": + return toAuthorizerDecision(b.accessWithEditor.Check(ctx, authlib.CheckRequest{ + Verb: apiutils.VerbGet, + Group: provisioning.GROUP, + Resource: provisioning.RepositoryResourceInfo.GetName(), + Name: a.GetName(), + Namespace: a.GetNamespace(), + }, "")) + + // Read-only subresources: resources, history, status (admin only) + case "resources", "history", "status": + return toAuthorizerDecision(b.accessWithAdmin.Check(ctx, authlib.CheckRequest{ + Verb: apiutils.VerbGet, + Group: provisioning.GROUP, + Resource: provisioning.RepositoryResourceInfo.GetName(), + Name: a.GetName(), + Namespace: a.GetNamespace(), + }, "")) + + // Jobs subresource - check jobs permissions with the verb (editors can manage jobs) + case "jobs": + return toAuthorizerDecision(b.accessWithEditor.Check(ctx, authlib.CheckRequest{ + Verb: a.GetVerb(), + Group: provisioning.GROUP, + Resource: provisioning.JobResourceInfo.GetName(), + Namespace: a.GetNamespace(), + }, "")) + default: + id, err := identity.GetRequester(ctx) + if err != nil { + return authorizer.DecisionDeny, "failed to find requester", err + } if id.GetIsGrafanaAdmin() { return authorizer.DecisionAllow, "", nil } @@ -460,8 +456,60 @@ func (b *APIBuilder) authorizeConnectionSubresource(a authorizer.Attributes, id } } +// authorizeConnectionSubresource handles authorization for connection subresources. +// Uses the access checker with verb-based authorization. +func (b *APIBuilder) authorizeConnectionSubresource(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { + switch a.GetSubresource() { + // Connection CRUD - use access checker with the actual verb + case "": + return toAuthorizerDecision(b.accessWithAdmin.Check(ctx, authlib.CheckRequest{ + Verb: a.GetVerb(), + Group: provisioning.GROUP, + Resource: provisioning.ConnectionResourceInfo.GetName(), + Name: a.GetName(), + Namespace: a.GetNamespace(), + }, "")) + + // Status is read-only + case "status": + return toAuthorizerDecision(b.accessWithAdmin.Check(ctx, authlib.CheckRequest{ + Verb: apiutils.VerbGet, + Group: provisioning.GROUP, + Resource: provisioning.ConnectionResourceInfo.GetName(), + Name: a.GetName(), + Namespace: a.GetNamespace(), + }, "")) + + default: + id, err := identity.GetRequester(ctx) + if err != nil { + return authorizer.DecisionDeny, "failed to find requester", err + } + if id.GetIsGrafanaAdmin() { + return authorizer.DecisionAllow, "", nil + } + return authorizer.DecisionDeny, "unmapped subresource defaults to no access", nil + } +} + +// ---------------------------------------------------------------------------- +// Authorization helpers +// ---------------------------------------------------------------------------- + +// toAuthorizerDecision converts an access check error to an authorizer decision tuple. +func toAuthorizerDecision(err error) (authorizer.Decision, string, error) { + if err != nil { + return authorizer.DecisionDeny, err.Error(), nil + } + return authorizer.DecisionAllow, "", nil +} + // authorizeDefault handles authorization for unmapped resources. -func (b *APIBuilder) authorizeDefault(id identity.Requester) (authorizer.Decision, string, error) { +func (b *APIBuilder) authorizeDefault(ctx context.Context) (authorizer.Decision, string, error) { + id, err := identity.GetRequester(ctx) + if err != nil { + return authorizer.DecisionDeny, "failed to find requester", err + } // We haven't bothered with this kind yet. if id.GetIsGrafanaAdmin() { return authorizer.DecisionAllow, "", nil @@ -558,7 +606,7 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI // TODO: Add some logic so that the connectors can registered themselves and we don't have logic all over the place storage[provisioning.RepositoryResourceInfo.StoragePath("test")] = NewTestConnector(b, repository.NewRepositoryTesterWithExistingChecker(repository.NewSimpleRepositoryTester(b.validator), b.VerifyAgainstExistingRepositories)) - storage[provisioning.RepositoryResourceInfo.StoragePath("files")] = NewFilesConnector(b, b.parsers, b.clients, b.access) + storage[provisioning.RepositoryResourceInfo.StoragePath("files")] = NewFilesConnector(b, b.parsers, b.clients, b.accessWithAdmin) storage[provisioning.RepositoryResourceInfo.StoragePath("refs")] = NewRefsConnector(b) storage[provisioning.RepositoryResourceInfo.StoragePath("resources")] = &listConnector{ getter: b, diff --git a/pkg/registry/apis/provisioning/resources/dualwriter.go b/pkg/registry/apis/provisioning/resources/dualwriter.go index 9180ace494d..e8a7ee83dd0 100644 --- a/pkg/registry/apis/provisioning/resources/dualwriter.go +++ b/pkg/registry/apis/provisioning/resources/dualwriter.go @@ -12,6 +12,7 @@ import ( authlib "github.com/grafana/authlib/types" "github.com/grafana/grafana-app-sdk/logging" provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/auth" "github.com/grafana/grafana/apps/provisioning/pkg/repository" "github.com/grafana/grafana/apps/provisioning/pkg/safepath" "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" @@ -32,7 +33,7 @@ type DualReadWriter struct { repo repository.ReaderWriter parser Parser folders *FolderManager - access authlib.AccessChecker + access auth.AccessChecker } type DualWriteOptions struct { @@ -48,7 +49,7 @@ type DualWriteOptions struct { Branch string // Configured default branch } -func NewDualReadWriter(repo repository.ReaderWriter, parser Parser, folders *FolderManager, access authlib.AccessChecker) *DualReadWriter { +func NewDualReadWriter(repo repository.ReaderWriter, parser Parser, folders *FolderManager, access auth.AccessChecker) *DualReadWriter { return &DualReadWriter{repo: repo, parser: parser, folders: folders, access: access} } @@ -492,11 +493,6 @@ func (r *DualReadWriter) moveFile(ctx context.Context, opts DualWriteOptions) (* } func (r *DualReadWriter) authorize(ctx context.Context, parsed *ParsedResource, verb string) error { - id, err := identity.GetRequester(ctx) - if err != nil { - return apierrors.NewUnauthorized(err.Error()) - } - var name string if parsed.Existing != nil { name = parsed.Existing.GetName() @@ -504,27 +500,15 @@ func (r *DualReadWriter) authorize(ctx context.Context, parsed *ParsedResource, name = parsed.Obj.GetName() } - rsp, err := r.access.Check(ctx, id, authlib.CheckRequest{ - Group: parsed.GVR.Group, - Resource: parsed.GVR.Resource, - Namespace: id.GetNamespace(), - Name: name, - Verb: verb, + return r.access.Check(ctx, authlib.CheckRequest{ + Group: parsed.GVR.Group, + Resource: parsed.GVR.Resource, + Name: name, + Verb: verb, }, parsed.Meta.GetFolder()) - if err != nil || !rsp.Allowed { - return apierrors.NewForbidden(parsed.GVR.GroupResource(), parsed.Obj.GetName(), - fmt.Errorf("no access to perform %s on the resource", verb)) - } - - return nil } func (r *DualReadWriter) authorizeCreateFolder(ctx context.Context, path string) error { - id, err := identity.GetRequester(ctx) - if err != nil { - return apierrors.NewUnauthorized(err.Error()) - } - // Determine parent folder from path parentFolder := "" if path != "" { @@ -537,19 +521,12 @@ func (r *DualReadWriter) authorizeCreateFolder(ctx context.Context, path string) } // For folder create operations, use empty name to check parent folder permissions - rsp, err := r.access.Check(ctx, id, authlib.CheckRequest{ - Group: FolderResource.Group, - Resource: FolderResource.Resource, - Namespace: id.GetNamespace(), - Name: "", // Empty name for create operations - Verb: utils.VerbCreate, + return r.access.Check(ctx, authlib.CheckRequest{ + Group: FolderResource.Group, + Resource: FolderResource.Resource, + Name: "", // Empty name for create operations + Verb: utils.VerbCreate, }, parentFolder) - if err != nil || !rsp.Allowed { - return apierrors.NewForbidden(FolderResource.GroupResource(), path, - fmt.Errorf("no access to create folder in parent folder '%s'", parentFolder)) - } - - return nil } func (r *DualReadWriter) deleteFolder(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) { diff --git a/pkg/services/authz/rbac/mapper.go b/pkg/services/authz/rbac/mapper.go index 9444d35d0ae..dcf2432fb2c 100644 --- a/pkg/services/authz/rbac/mapper.go +++ b/pkg/services/authz/rbac/mapper.go @@ -279,8 +279,11 @@ func NewMapperRegistry() MapperRegistry { }, "provisioning.grafana.app": { "repositories": newResourceTranslation("provisioning.repositories", "uid", false, skipScopeOnAllVerbs), + "connections": newResourceTranslation("provisioning.connections", "uid", false, skipScopeOnAllVerbs), "jobs": newResourceTranslation("provisioning.jobs", "uid", false, skipScopeOnAllVerbs), "historicjobs": newResourceTranslation("provisioning.historicjobs", "uid", false, skipScopeOnAllVerbs), + "settings": newResourceTranslation("provisioning.settings", "", false, skipScopeOnAllVerbs), + "stats": newResourceTranslation("provisioning.stats", "", false, skipScopeOnAllVerbs), }, "secret.grafana.app": { "securevalues": newResourceTranslation("secret.securevalues", "uid", false, nil), diff --git a/pkg/tests/apis/provisioning/connection_status_auth_test.go b/pkg/tests/apis/provisioning/connection_status_auth_test.go new file mode 100644 index 00000000000..fbddd85999a --- /dev/null +++ b/pkg/tests/apis/provisioning/connection_status_auth_test.go @@ -0,0 +1,88 @@ +package provisioning + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/grafana/grafana/pkg/util/testutil" +) + +func TestIntegrationProvisioning_ConnectionStatusAuthorization(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := runGrafana(t) + ctx := context.Background() + createOptions := metav1.CreateOptions{FieldValidation: "Strict"} + + // Create a connection for testing + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection-status-test", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "github", + "github": map[string]any{ + "appID": "123456", + "installationID": "454545", + }, + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": "someSecret", + }, + }, + }} + + _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + require.NoError(t, err, "failed to create connection") + + t.Run("admin can GET connection status", func(t *testing.T) { + var statusCode int + result := helper.AdminREST.Get(). + Namespace("default"). + Resource("connections"). + Name("connection-status-test"). + SubResource("status"). + Do(ctx).StatusCode(&statusCode) + + require.NoError(t, result.Error(), "admin should be able to GET connection status") + require.Equal(t, http.StatusOK, statusCode, "should return 200 OK") + }) + + t.Run("editor cannot GET connection status", func(t *testing.T) { + var statusCode int + result := helper.EditorREST.Get(). + Namespace("default"). + Resource("connections"). + Name("connection-status-test"). + SubResource("status"). + Do(ctx).StatusCode(&statusCode) + + require.Error(t, result.Error(), "editor should not be able to GET connection status") + require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden") + require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden") + }) + + t.Run("viewer cannot GET connection status", func(t *testing.T) { + var statusCode int + result := helper.ViewerREST.Get(). + Namespace("default"). + Resource("connections"). + Name("connection-status-test"). + SubResource("status"). + Do(ctx).StatusCode(&statusCode) + + require.Error(t, result.Error(), "viewer should not be able to GET connection status") + require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden") + require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden") + }) +} diff --git a/pkg/tests/apis/provisioning/historicjobs_auth_test.go b/pkg/tests/apis/provisioning/historicjobs_auth_test.go new file mode 100644 index 00000000000..fc11f8d00b3 --- /dev/null +++ b/pkg/tests/apis/provisioning/historicjobs_auth_test.go @@ -0,0 +1,95 @@ +package provisioning + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/pkg/util/testutil" +) + +func TestIntegrationProvisioning_HistoricJobsAuthorization(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := runGrafana(t) + ctx := context.Background() + + const repo = "historicjobs-auth-test" + testRepo := TestRepo{ + Name: repo, + Target: "folder", + Copies: map[string]string{}, // No files needed for this test + ExpectedDashboards: 0, + ExpectedFolders: 1, // Repository creates a folder + } + helper.CreateRepo(t, testRepo) + + // Trigger a job to create a historic job entry + jobSpec := provisioning.JobSpec{ + Action: provisioning.JobActionPull, + Pull: &provisioning.SyncJobOptions{}, + } + body := asJSON(jobSpec) + + // Create a job as admin + var statusCode int + result := helper.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("jobs"). + Body(body). + SetHeader("Content-Type", "application/json"). + Do(ctx).StatusCode(&statusCode) + require.NoError(t, result.Error(), "should be able to create job") + require.Equal(t, http.StatusAccepted, statusCode) + + // Wait for job to complete and become historic + helper.AwaitJobs(t, repo) + historicJob := helper.AwaitLatestHistoricJob(t, repo) + require.NotNil(t, historicJob, "should have a historic job") + + historicJobName := historicJob.GetName() + + t.Run("admin can GET historic job", func(t *testing.T) { + var statusCode int + result := helper.AdminREST.Get(). + Namespace("default"). + Resource("historicjobs"). + Name(historicJobName). + Do(ctx).StatusCode(&statusCode) + + require.NoError(t, result.Error(), "admin should be able to GET historic job") + require.Equal(t, http.StatusOK, statusCode, "should return 200 OK") + }) + + t.Run("editor cannot GET historic job", func(t *testing.T) { + var statusCode int + result := helper.EditorREST.Get(). + Namespace("default"). + Resource("historicjobs"). + Name(historicJobName). + Do(ctx).StatusCode(&statusCode) + + require.Error(t, result.Error(), "editor should not be able to GET historic job") + require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden") + require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden") + }) + + t.Run("viewer cannot GET historic job", func(t *testing.T) { + var statusCode int + result := helper.ViewerREST.Get(). + Namespace("default"). + Resource("historicjobs"). + Name(historicJobName). + Do(ctx).StatusCode(&statusCode) + + require.Error(t, result.Error(), "viewer should not be able to GET historic job") + require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden") + require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden") + }) +} diff --git a/pkg/tests/apis/provisioning/repository_subresources_auth_test.go b/pkg/tests/apis/provisioning/repository_subresources_auth_test.go new file mode 100644 index 00000000000..54a2f173b51 --- /dev/null +++ b/pkg/tests/apis/provisioning/repository_subresources_auth_test.go @@ -0,0 +1,236 @@ +package provisioning + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + + "github.com/grafana/grafana/pkg/util/testutil" +) + +func TestIntegrationProvisioning_RepositorySubresourcesAuthorization(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := runGrafana(t) + ctx := context.Background() + + const repo = "subresources-auth-test" + testRepo := TestRepo{ + Name: repo, + Target: "folder", + Copies: map[string]string{}, // No files needed for this test + ExpectedDashboards: 0, + ExpectedFolders: 1, // Repository creates a folder + } + helper.CreateRepo(t, testRepo) + + t.Run("test subresource", func(t *testing.T) { + newRepoConfig := map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Repository", + "spec": map[string]any{ + "title": "Test Configuration", + "type": "local", + "local": map[string]any{ + "path": helper.ProvisioningPath, + }, + "workflows": []string{"write"}, + "sync": map[string]any{ + "enabled": true, + "target": "folder", + "intervalSeconds": 10, + }, + }, + } + configBytes, err := json.Marshal(newRepoConfig) + require.NoError(t, err) + + t.Run("admin can POST test", func(t *testing.T) { + var statusCode int + result := helper.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name("test-config-auth"). + SubResource("test"). + Body(configBytes). + SetHeader("Content-Type", "application/json"). + Do(ctx).StatusCode(&statusCode) + + require.NoError(t, result.Error(), "admin should be able to POST test") + require.Equal(t, http.StatusOK, statusCode, "should return 200 OK") + }) + + t.Run("editor cannot POST test", func(t *testing.T) { + var statusCode int + result := helper.EditorREST.Post(). + Namespace("default"). + Resource("repositories"). + Name("test-config-auth"). + SubResource("test"). + Body(configBytes). + SetHeader("Content-Type", "application/json"). + Do(ctx).StatusCode(&statusCode) + + require.Error(t, result.Error(), "editor should not be able to POST test") + require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden") + require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden") + }) + + t.Run("viewer cannot POST test", func(t *testing.T) { + var statusCode int + result := helper.ViewerREST.Post(). + Namespace("default"). + Resource("repositories"). + Name("test-config-auth"). + SubResource("test"). + Body(configBytes). + SetHeader("Content-Type", "application/json"). + Do(ctx).StatusCode(&statusCode) + + require.Error(t, result.Error(), "viewer should not be able to POST test") + require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden") + require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden") + }) + }) + + t.Run("resources subresource", func(t *testing.T) { + t.Run("admin can GET resources", func(t *testing.T) { + var statusCode int + result := helper.AdminREST.Get(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("resources"). + Do(ctx).StatusCode(&statusCode) + + require.NoError(t, result.Error(), "admin should be able to GET resources") + require.Equal(t, http.StatusOK, statusCode, "should return 200 OK") + }) + + t.Run("editor cannot GET resources", func(t *testing.T) { + var statusCode int + result := helper.EditorREST.Get(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("resources"). + Do(ctx).StatusCode(&statusCode) + + require.Error(t, result.Error(), "editor should not be able to GET resources") + require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden") + require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden") + }) + + t.Run("viewer cannot GET resources", func(t *testing.T) { + var statusCode int + result := helper.ViewerREST.Get(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("resources"). + Do(ctx).StatusCode(&statusCode) + + require.Error(t, result.Error(), "viewer should not be able to GET resources") + require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden") + require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden") + }) + }) + + t.Run("history subresource", func(t *testing.T) { + t.Run("admin can GET history (or BadRequest if not supported)", func(t *testing.T) { + var statusCode int + result := helper.AdminREST.Get(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("history"). + Do(ctx).StatusCode(&statusCode) + + // Admin should pass authorization - may get BadRequest if repo doesn't support history + // but should NOT get Forbidden (which would indicate authorization failure) + if result.Error() != nil { + require.False(t, apierrors.IsForbidden(result.Error()), "admin should not get Forbidden error") + // Local repos don't support history, so BadRequest is expected + require.True(t, apierrors.IsBadRequest(result.Error()) || statusCode == http.StatusBadRequest, + "should get BadRequest if history not supported, not Forbidden") + } else { + require.Equal(t, http.StatusOK, statusCode, "should return 200 OK if history is supported") + } + }) + + t.Run("editor cannot GET history", func(t *testing.T) { + var statusCode int + result := helper.EditorREST.Get(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("history"). + Do(ctx).StatusCode(&statusCode) + + require.Error(t, result.Error(), "editor should not be able to GET history") + require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden") + require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden") + }) + + t.Run("viewer cannot GET history", func(t *testing.T) { + var statusCode int + result := helper.ViewerREST.Get(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("history"). + Do(ctx).StatusCode(&statusCode) + + require.Error(t, result.Error(), "viewer should not be able to GET history") + require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden") + require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden") + }) + }) + + t.Run("status subresource", func(t *testing.T) { + t.Run("admin can GET status", func(t *testing.T) { + var statusCode int + result := helper.AdminREST.Get(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("status"). + Do(ctx).StatusCode(&statusCode) + + require.NoError(t, result.Error(), "admin should be able to GET status") + require.Equal(t, http.StatusOK, statusCode, "should return 200 OK") + }) + + t.Run("editor cannot GET status", func(t *testing.T) { + var statusCode int + result := helper.EditorREST.Get(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("status"). + Do(ctx).StatusCode(&statusCode) + + require.Error(t, result.Error(), "editor should not be able to GET status") + require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden") + require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden") + }) + + t.Run("viewer cannot GET status", func(t *testing.T) { + var statusCode int + result := helper.ViewerREST.Get(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("status"). + Do(ctx).StatusCode(&statusCode) + + require.Error(t, result.Error(), "viewer should not be able to GET status") + require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden") + require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden") + }) + }) +} diff --git a/pkg/tests/apis/provisioning/repository_test.go b/pkg/tests/apis/provisioning/repository_test.go index 3cee3cf4aba..f2447e71d23 100644 --- a/pkg/tests/apis/provisioning/repository_test.go +++ b/pkg/tests/apis/provisioning/repository_test.go @@ -956,3 +956,66 @@ func TestIntegrationProvisioning_JobPermissions(t *testing.T) { require.Equal(t, http.StatusAccepted, statusCode, "should return 202 Accepted") }) } + +func TestIntegrationProvisioning_RefsPermissions(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := runGrafana(t) + ctx := context.Background() + + const repo = "refs-permissions-test" + testRepo := TestRepo{ + Name: repo, + Template: "testdata/github-readonly.json.tmpl", + Target: "folder", + ExpectedDashboards: 3, + ExpectedFolders: 3, // Repository creates folders + } + helper.CreateRepo(t, testRepo) + + t.Run("editor can GET refs", func(t *testing.T) { + var statusCode int + result := helper.EditorREST.Get(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("refs"). + Do(ctx).StatusCode(&statusCode) + + require.NoError(t, result.Error(), "editor should be able to GET refs") + require.Equal(t, http.StatusOK, statusCode, "should return 200 OK") + + // Verify we can parse the refs and it contains at least main branch + refs := &provisioning.RefList{} + err := result.Into(refs) + require.NoError(t, err, "should parse refs response") + require.NotEmpty(t, refs.Items, "should have at least one ref") + }) + + t.Run("viewer cannot GET refs", func(t *testing.T) { + var statusCode int + result := helper.ViewerREST.Get(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("refs"). + Do(ctx).StatusCode(&statusCode) + + require.Error(t, result.Error(), "viewer should not be able to GET refs") + require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden") + require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden") + }) + + t.Run("admin can GET refs", func(t *testing.T) { + var statusCode int + result := helper.AdminREST.Get(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("refs"). + Do(ctx).StatusCode(&statusCode) + + require.NoError(t, result.Error(), "admin should be able to GET refs") + require.Equal(t, http.StatusOK, statusCode, "should return 200 OK") + }) +} diff --git a/pkg/tests/apis/provisioning/settings_stats_auth_test.go b/pkg/tests/apis/provisioning/settings_stats_auth_test.go new file mode 100644 index 00000000000..d438066235a --- /dev/null +++ b/pkg/tests/apis/provisioning/settings_stats_auth_test.go @@ -0,0 +1,104 @@ +package provisioning + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + + "github.com/grafana/grafana/pkg/util/testutil" +) + +func TestIntegrationProvisioning_SettingsAuthorization(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := runGrafana(t) + ctx := context.Background() + + t.Run("viewer can GET settings", func(t *testing.T) { + var statusCode int + result := helper.ViewerREST.Get(). + Namespace("default"). + Resource("settings"). + Do(ctx).StatusCode(&statusCode) + + require.NoError(t, result.Error(), "viewer should be able to GET settings") + require.Equal(t, http.StatusOK, statusCode, "should return 200 OK") + }) + + t.Run("editor can GET settings", func(t *testing.T) { + var statusCode int + result := helper.EditorREST.Get(). + Namespace("default"). + Resource("settings"). + Do(ctx).StatusCode(&statusCode) + + require.NoError(t, result.Error(), "editor should be able to GET settings") + require.Equal(t, http.StatusOK, statusCode, "should return 200 OK") + }) + + t.Run("admin can GET settings", func(t *testing.T) { + var statusCode int + result := helper.AdminREST.Get(). + Namespace("default"). + Resource("settings"). + Do(ctx).StatusCode(&statusCode) + + require.NoError(t, result.Error(), "admin should be able to GET settings") + require.Equal(t, http.StatusOK, statusCode, "should return 200 OK") + }) +} + +func TestIntegrationProvisioning_StatsAuthorization(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := runGrafana(t) + ctx := context.Background() + + // Create a repository to ensure stats endpoint has data + const repo = "stats-auth-test" + helper.CreateRepo(t, TestRepo{ + Name: repo, + Target: "folder", + Copies: map[string]string{}, + ExpectedDashboards: 0, + ExpectedFolders: 1, + }) + + t.Run("admin can GET stats", func(t *testing.T) { + var statusCode int + result := helper.AdminREST.Get(). + Namespace("default"). + Resource("stats"). + Do(ctx).StatusCode(&statusCode) + + require.NoError(t, result.Error(), "admin should be able to GET stats") + require.Equal(t, http.StatusOK, statusCode, "should return 200 OK") + }) + + t.Run("editor cannot GET stats", func(t *testing.T) { + var statusCode int + result := helper.EditorREST.Get(). + Namespace("default"). + Resource("stats"). + Do(ctx).StatusCode(&statusCode) + + require.Error(t, result.Error(), "editor should not be able to GET stats") + require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden") + require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden") + }) + + t.Run("viewer cannot GET stats", func(t *testing.T) { + var statusCode int + result := helper.ViewerREST.Get(). + Namespace("default"). + Resource("stats"). + Do(ctx).StatusCode(&statusCode) + + require.Error(t, result.Error(), "viewer should not be able to GET stats") + require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden") + require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden") + }) +} From 19c9f21cc4ca23f75851eaaa7ef38d42c3b641aa Mon Sep 17 00:00:00 2001 From: Anna Urbiztondo Date: Fri, 19 Dec 2025 15:13:35 +0100 Subject: [PATCH 049/229] Docs: Corrections for full instance sync (#115615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Corrections for full instance sync * Edits * Feedback * Migration checkbox * Edit * Update docs/sources/as-code/observability-as-code/provision-resources/git-sync-setup.md Co-authored-by: Roberto Jiménez Sánchez * Mention to export * Prettier --------- Co-authored-by: Roberto Jiménez Sánchez --- .../provision-resources/file-path-setup.md | 23 ++++++++----- .../provision-resources/git-sync-setup.md | 32 +++++++++++++++---- .../provision-resources/intro-git-sync.md | 4 +-- 3 files changed, 42 insertions(+), 17 deletions(-) diff --git a/docs/sources/as-code/observability-as-code/provision-resources/file-path-setup.md b/docs/sources/as-code/observability-as-code/provision-resources/file-path-setup.md index d071ccca0de..4c0d22c3be9 100644 --- a/docs/sources/as-code/observability-as-code/provision-resources/file-path-setup.md +++ b/docs/sources/as-code/observability-as-code/provision-resources/file-path-setup.md @@ -128,25 +128,32 @@ The set up process verifies the path and provides an error message if a problem #### Synchronization limitations -Full instance sync is not available in Grafana Cloud. +{{< admonition type="caution" >}} -In Grafana OSS/Enterprise: +Full instance sync is not available in Grafana Cloud and is experimental and unsupported in Grafana OSS/Enterprise. + +{{< /admonition >}} + +To have access to full instance sync you must explicitly enable the option. + +The following applies: -- If you try to perform a full instance sync with resources that contain alerts or panels, the connection will be blocked. - You won't be able to create new alerts or library panels after setup is completed. - If you opted for full instance sync and want to use alerts and library panels, you'll have to delete the provisioned repository and connect again with folder sync. #### Set up synchronization -Choose to either sync your entire organization resources with external storage, or to sync certain resources to a new Grafana folder (with up to 10 connections). +You can sync external resources into a new folder without affecting the rest of your instance. -- Choose **Sync all resources with external storage** if you want to sync and manage your entire Grafana instance through external storage. With this option, all of your dashboards are synced to that one repository. You can only have one provisioned connection with this selection, and you won't have the option of setting up additional repositories to connect to. +To set up synchronization: -- Choose **Sync external storage to new Grafana folder** to sync external resources into a new folder without affecting the rest of your instance. You can repeat this process for up to 10 connections. +1. Select which resources you want to sync. -Next, enter a **Display name** for the repository connection. Resources stored in this connection appear under the chosen display name in the Grafana UI. +1. Enter a **Display name** for the repository connection. Resources stored in this connection appear under the chosen display name in the Grafana UI. -Click **Synchronize** to continue. +1. Click **Synchronize** to continue. + +1. You can repeat this process for up to 10 connections. ### Synchronize with external storage diff --git a/docs/sources/as-code/observability-as-code/provision-resources/git-sync-setup.md b/docs/sources/as-code/observability-as-code/provision-resources/git-sync-setup.md index cd07c532dae..b43c279fb44 100644 --- a/docs/sources/as-code/observability-as-code/provision-resources/git-sync-setup.md +++ b/docs/sources/as-code/observability-as-code/provision-resources/git-sync-setup.md @@ -132,17 +132,35 @@ To connect your GitHub repository: ### Choose what to synchronize -In this step, you can decide which elements to synchronize. The available options depend on the status of your Grafana instance: - -- If the instance contains resources in an incompatible data format, you'll have to migrate all the data using instance sync. Folder sync won't be supported. -- If there's already another connection using folder sync, instance sync won't be offered. +You can sync external resources into a new folder without affecting the rest of your instance. To set up synchronization: -- Choose **Sync all resources with external storage** if you want to sync and manage your entire Grafana instance through external storage. With this option, all of your dashboards are synced to that one repository. You can only have one provisioned connection with this selection, and you won't have the option of setting up additional repositories to connect to. -- Choose **Sync external storage to new Grafana folder** to sync external resources into a new folder without affecting the rest of your instance. You can repeat this process for up to 10 connections. +1. Select which resources you want to sync. -Next, enter a **Display name** for the repository connection. Resources stored in this connection appear under the chosen display name in the Grafana UI. Click **Synchronize** to continue. +1. Enter a **Display name** for the repository connection. Resources stored in this connection appear under the chosen display name in the Grafana UI. + +1. Click **Synchronize** to continue. + +1. You can repeat this process for up to 10 connections. + +{{< admonition type="note" >}} + +Optionally, you can export any unmanaged resources into the provisioned folder. See how in [Synchronize with external storage](#synchronize-with-external-storage). + +{{< /admonition >}} + +#### Full instance sync + +Full instance sync is not available in Grafana Cloud and is experimental and unsupported in Grafana OSS/Enterprise. + +To have access to this option you must enable experimental instance sync on purpose. + +### Synchronize with external storage + +After this one time step, all future updates are automatically saved to the Git repository and provisioned back to the instance. + +Check the **Migrate existing resources** box to migrate your unmanaged dashboards to the provisioned folder. ### Choose additional settings diff --git a/docs/sources/as-code/observability-as-code/provision-resources/intro-git-sync.md b/docs/sources/as-code/observability-as-code/provision-resources/intro-git-sync.md index 9e32ce32e8a..903df6303a6 100644 --- a/docs/sources/as-code/observability-as-code/provision-resources/intro-git-sync.md +++ b/docs/sources/as-code/observability-as-code/provision-resources/intro-git-sync.md @@ -47,7 +47,7 @@ Using Git Sync, you can: {{< admonition type="caution" >}} -Git Sync only works with specific folders for the moment. Full-instance sync is not currently supported. +Full instance sync is not available in Grafana Cloud and is experimental and unsupported in Grafana OSS/Enterprise. {{< /admonition >}} @@ -84,7 +84,7 @@ Refer to [Requirements](https://grafana.com/docs/grafana//obser - You can only sync dashboards and folders. Refer to [Supported resources](#supported-resources) for more information. - If you're using Git Sync in Grafana OSS and Grafana Enterprise, some resources might be in an incompatible data format and won't be synced. -- Full-instance sync is not available in Grafana Cloud and has limitations in Grafana OSS and Grafana Enterprise. Refer to [Choose what to synchronize](../git-sync-setup/#choose-what-to-synchronize) for more details. +- Full-instance sync is not available in Grafana Cloud and is experimental in Grafana OSS and Grafana Enterprise. Refer to [Choose what to synchronize](../git-sync-setup/#choose-what-to-synchronize) for more details. - When migrating to full instance sync, during the synchronization process your resources will be temporarily unavailable. No one will be able to create, edit, or delete resources during this process. - If you want to manage existing resources with Git Sync, you need to save them as JSON files and commit them to the synced repository. Open a PR to import, copy, move, or save a dashboard. - Restoring resources from the UI is currently not possible. As an alternative, you can restore dashboards directly in your GitHub repository by raising a PR, and they will be updated in Grafana. From 338ae95ef53fa7f7f36cb754673ca4c5c92502d3 Mon Sep 17 00:00:00 2001 From: Renato Costa <103441181+renatolabs@users.noreply.github.com> Date: Fri, 19 Dec 2025 09:15:23 -0500 Subject: [PATCH 050/229] unified-storage: add `BatchDelete` support to sqlkv implementation (#115573) --- .../resource/data/sqlkv_batch_delete.sql | 7 ++ pkg/storage/unified/resource/sqlkv.go | 31 ++++++--- pkg/storage/unified/testing/kv.go | 65 ++++++++++--------- pkg/storage/unified/testing/kv_test.go | 1 - 4 files changed, 63 insertions(+), 41 deletions(-) create mode 100644 pkg/storage/unified/resource/data/sqlkv_batch_delete.sql diff --git a/pkg/storage/unified/resource/data/sqlkv_batch_delete.sql b/pkg/storage/unified/resource/data/sqlkv_batch_delete.sql new file mode 100644 index 00000000000..c165007d23d --- /dev/null +++ b/pkg/storage/unified/resource/data/sqlkv_batch_delete.sql @@ -0,0 +1,7 @@ +DELETE +FROM {{ .TableName }} +WHERE {{ .Ident "key_path" }} IN ( + {{ range $id, $key_path := .KeyPaths }} + {{ if ne $id 0 }}, {{ end }}{{ $.Arg $key_path }} + {{ end }} +); diff --git a/pkg/storage/unified/resource/sqlkv.go b/pkg/storage/unified/resource/sqlkv.go index 22cd3085122..3c07403296b 100644 --- a/pkg/storage/unified/resource/sqlkv.go +++ b/pkg/storage/unified/resource/sqlkv.go @@ -34,10 +34,11 @@ func mustTemplate(filename string) *template.Template { // Templates. var ( - sqlKVKeys = mustTemplate("sqlkv_keys.sql") - sqlKVGet = mustTemplate("sqlkv_get.sql") - sqlKVBatchGet = mustTemplate("sqlkv_batch_get.sql") - sqlKVDelete = mustTemplate("sqlkv_delete.sql") + sqlKVKeys = mustTemplate("sqlkv_keys.sql") + sqlKVGet = mustTemplate("sqlkv_get.sql") + sqlKVBatchGet = mustTemplate("sqlkv_batch_get.sql") + sqlKVDelete = mustTemplate("sqlkv_delete.sql") + sqlKVBatchDelete = mustTemplate("sqlkv_batch_delete.sql") ) // sqlKVSection can be embedded in structs used when rendering query templates @@ -108,17 +109,17 @@ func (req sqlKVGetRequest) Results() ([]byte, error) { return req.Value, nil } -type sqlKVBatchGetRequest struct { +type sqlKVBatchRequest struct { sqltemplate.SQLTemplate sqlKVSection Keys []string } -func (req sqlKVBatchGetRequest) Validate() error { +func (req sqlKVBatchRequest) Validate() error { return req.sqlKVSection.Validate() } -func (req sqlKVBatchGetRequest) KeyPaths() []string { +func (req sqlKVBatchRequest) KeyPaths() []string { result := make([]string, 0, len(req.Keys)) for _, key := range req.Keys { result = append(result, req.Section+"/"+key) @@ -250,7 +251,7 @@ func (k *sqlKV) BatchGet(ctx context.Context, section string, keys []string) ite return } - rows, err := dbutil.QueryRows(ctx, k.db, sqlKVBatchGet, sqlKVBatchGetRequest{ + rows, err := dbutil.QueryRows(ctx, k.db, sqlKVBatchGet, sqlKVBatchRequest{ SQLTemplate: sqltemplate.New(k.dialect), sqlKVSection: sqlKVSection{section}, Keys: keys, @@ -322,7 +323,19 @@ func (k *sqlKV) Delete(ctx context.Context, section string, key string) error { } func (k *sqlKV) BatchDelete(ctx context.Context, section string, keys []string) error { - panic("not implemented!") + if len(keys) == 0 { + return nil + } + + if _, err := dbutil.Exec(ctx, k.db, sqlKVBatchDelete, sqlKVBatchRequest{ + SQLTemplate: sqltemplate.New(k.dialect), + sqlKVSection: sqlKVSection{section}, + Keys: keys, + }); err != nil { + return fmt.Errorf("failed to batch delete keys: %w", err) + } + + return nil } func (k *sqlKV) UnixTimestamp(ctx context.Context) (int64, error) { diff --git a/pkg/storage/unified/testing/kv.go b/pkg/storage/unified/testing/kv.go index fbd831f6281..770bf9ac6e6 100644 --- a/pkg/storage/unified/testing/kv.go +++ b/pkg/storage/unified/testing/kv.go @@ -5,6 +5,8 @@ import ( "context" "fmt" "io" + "maps" + "slices" "strings" "testing" "time" @@ -786,35 +788,35 @@ func runTestKVBatchGet(t *testing.T, kv resource.KV, nsPrefix string) { func runTestKVBatchDelete(t *testing.T, kv resource.KV, nsPrefix string) { ctx := testutil.NewTestContext(t, time.Now().Add(30*time.Second)) - section := nsPrefix + "-batchdelete" + nsPrefix += "-batchdelete" t.Run("batch delete existing keys", func(t *testing.T) { // Setup test data testData := map[string]string{ - "key1": "value1", - "key2": "value2", - "key3": "value3", + namespacedKey(nsPrefix, "key1"): "value1", + namespacedKey(nsPrefix, "key2"): "value2", + namespacedKey(nsPrefix, "key3"): "value3", } // Save test data for key, value := range testData { - saveKVHelper(t, kv, ctx, section, key, strings.NewReader(value)) + saveKVHelper(t, kv, ctx, testSection, key, strings.NewReader(value)) } // Verify keys exist before deletion for key := range testData { - _, err := kv.Get(ctx, section, key) + _, err := kv.Get(ctx, testSection, key) require.NoError(t, err) } // Batch delete all keys - keys := []string{"key1", "key2", "key3"} - err := kv.BatchDelete(ctx, section, keys) + keys := slices.Collect(maps.Keys(testData)) + err := kv.BatchDelete(ctx, testSection, keys) require.NoError(t, err) // Verify all keys are deleted for _, key := range keys { - _, err := kv.Get(ctx, section, key) + _, err := kv.Get(ctx, testSection, key) assert.Error(t, err) assert.Equal(t, resource.ErrNotFound, err) } @@ -822,39 +824,40 @@ func runTestKVBatchDelete(t *testing.T, kv resource.KV, nsPrefix string) { t.Run("batch delete with non-existent keys", func(t *testing.T) { // Setup some test data - saveKVHelper(t, kv, ctx, section, "existing-key-1", strings.NewReader("value1")) - saveKVHelper(t, kv, ctx, section, "existing-key-2", strings.NewReader("value2")) + key1, key2 := namespacedKey(nsPrefix, "existing-key-1"), namespacedKey(nsPrefix, "existing-key-2") + saveKVHelper(t, kv, ctx, testSection, key1, strings.NewReader("value1")) + saveKVHelper(t, kv, ctx, testSection, key2, strings.NewReader("value2")) // Batch delete with mix of existing and non-existent keys - keys := []string{"existing-key-1", "non-existent-1", "existing-key-2", "non-existent-2"} - err := kv.BatchDelete(ctx, section, keys) + keys := []string{key1, namespacedKey(nsPrefix, "non-existent-1"), key2, namespacedKey(nsPrefix, "non-existent-2")} + err := kv.BatchDelete(ctx, testSection, keys) require.NoError(t, err) // Verify existing keys are deleted - _, err = kv.Get(ctx, section, "existing-key-1") - assert.Error(t, err) + _, err = kv.Get(ctx, testSection, key1) + require.Error(t, err) assert.Equal(t, resource.ErrNotFound, err) - _, err = kv.Get(ctx, section, "existing-key-2") - assert.Error(t, err) + _, err = kv.Get(ctx, testSection, key2) + require.Error(t, err) assert.Equal(t, resource.ErrNotFound, err) }) t.Run("batch delete with all non-existent keys", func(t *testing.T) { // Batch delete keys that don't exist - keys := []string{"non-existent-1", "non-existent-2", "non-existent-3"} - err := kv.BatchDelete(ctx, section, keys) + keys := namespacedKeys(nsPrefix, []string{"non-existent-1", "non-existent-2", "non-existent-3"}) + err := kv.BatchDelete(ctx, testSection, keys) require.NoError(t, err) }) t.Run("batch delete with empty keys list", func(t *testing.T) { keys := []string{} - err := kv.BatchDelete(ctx, section, keys) + err := kv.BatchDelete(ctx, testSection, keys) require.NoError(t, err) }) t.Run("batch delete with empty section", func(t *testing.T) { - keys := []string{"some-key"} + keys := namespacedKeys(nsPrefix, []string{"some-key"}) err := kv.BatchDelete(ctx, "", keys) assert.Error(t, err) assert.Contains(t, err.Error(), "section is required") @@ -862,27 +865,27 @@ func runTestKVBatchDelete(t *testing.T, kv resource.KV, nsPrefix string) { t.Run("batch delete preserves other keys", func(t *testing.T) { // Setup test data - saveKVHelper(t, kv, ctx, section, "keep-key-1", strings.NewReader("keep-value-1")) - saveKVHelper(t, kv, ctx, section, "delete-key-1", strings.NewReader("delete-value-1")) - saveKVHelper(t, kv, ctx, section, "keep-key-2", strings.NewReader("keep-value-2")) - saveKVHelper(t, kv, ctx, section, "delete-key-2", strings.NewReader("delete-value-2")) + saveKVHelper(t, kv, ctx, testSection, namespacedKey(nsPrefix, "keep-key-1"), strings.NewReader("keep-value-1")) + saveKVHelper(t, kv, ctx, testSection, namespacedKey(nsPrefix, "delete-key-1"), strings.NewReader("delete-value-1")) + saveKVHelper(t, kv, ctx, testSection, namespacedKey(nsPrefix, "keep-key-2"), strings.NewReader("keep-value-2")) + saveKVHelper(t, kv, ctx, testSection, namespacedKey(nsPrefix, "delete-key-2"), strings.NewReader("delete-value-2")) // Batch delete specific keys - keys := []string{"delete-key-1", "delete-key-2"} - err := kv.BatchDelete(ctx, section, keys) + keys := namespacedKeys(nsPrefix, []string{"delete-key-1", "delete-key-2"}) + err := kv.BatchDelete(ctx, testSection, keys) require.NoError(t, err) // Verify deleted keys are gone - _, err = kv.Get(ctx, section, "delete-key-1") + _, err = kv.Get(ctx, testSection, namespacedKey(nsPrefix, "delete-key-1")) assert.Error(t, err) assert.Equal(t, resource.ErrNotFound, err) - _, err = kv.Get(ctx, section, "delete-key-2") + _, err = kv.Get(ctx, testSection, namespacedKey(nsPrefix, "delete-key-2")) assert.Error(t, err) assert.Equal(t, resource.ErrNotFound, err) // Verify kept keys still exist - reader, err := kv.Get(ctx, section, "keep-key-1") + reader, err := kv.Get(ctx, testSection, namespacedKey(nsPrefix, "keep-key-1")) require.NoError(t, err) value, err := io.ReadAll(reader) require.NoError(t, err) @@ -890,7 +893,7 @@ func runTestKVBatchDelete(t *testing.T, kv resource.KV, nsPrefix string) { err = reader.Close() require.NoError(t, err) - reader, err = kv.Get(ctx, section, "keep-key-2") + reader, err = kv.Get(ctx, testSection, namespacedKey(nsPrefix, "keep-key-2")) require.NoError(t, err) value, err = io.ReadAll(reader) require.NoError(t, err) diff --git a/pkg/storage/unified/testing/kv_test.go b/pkg/storage/unified/testing/kv_test.go index dafefc15ed2..3814df17b7e 100644 --- a/pkg/storage/unified/testing/kv_test.go +++ b/pkg/storage/unified/testing/kv_test.go @@ -50,7 +50,6 @@ func TestSQLKV(t *testing.T) { TestKVSave: true, TestKVConcurrent: true, TestKVUnixTimestamp: true, - TestKVBatchDelete: true, }, }) } From 133865182e125b73d902ce07176abdc89fffe084 Mon Sep 17 00:00:00 2001 From: Matheus Macabu Date: Fri, 19 Dec 2025 15:21:22 +0100 Subject: [PATCH 051/229] CI: Add e2e-playwright folder to e2e test detection changes (#115623) --- .github/actions/change-detection/action.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/change-detection/action.yml b/.github/actions/change-detection/action.yml index 863cab646b9..2b8484d7cf6 100644 --- a/.github/actions/change-detection/action.yml +++ b/.github/actions/change-detection/action.yml @@ -99,6 +99,7 @@ runs: - '${{ inputs.self }}' e2e: - 'e2e/**' + - 'e2e-playwright/**' - '.github/actions/setup-enterprise/**' - '.github/actions/checkout/**' - 'emails/**' From 62b2a202de1463cdfd3be0755b2805266d3b046a Mon Sep 17 00:00:00 2001 From: Deyan Halachliyski <119334180+dhalachliyski@users.noreply.github.com> Date: Fri, 19 Dec 2025 15:32:27 +0100 Subject: [PATCH 052/229] Alerting: Add saved searches feature for alert rules page (#115001) * Alerting: Add saved searches feature for alert rules page Add ability to save, rename, delete, and apply search queries on the Alert Rules page. Includes auto-apply default search on navigation and UserStorage persistence. Behind feature toggle `alertingSavedSearches` (disabled by default). * Alerting: Add i18n translations for saved searches * Alerting: Remove unused imports in saved searches * Alerting: Add CODEOWNERS for e2e-playwright/alerting-suite * Alerting: Add useSavedSearches mock to RulesFilter.v2 tests * Alerting: Fix failing unit tests for saved searches - Fix Jest mock hoisting issue in useSavedSearches.test.ts by configuring UserStorage mock implementation after imports instead of inline - Update SavedSearches.test.tsx to use findBy* queries for async popup content - Fix tests to click apply button instead of text for applying searches - Update maxLength test to verify attribute instead of trying to exceed it * Alerting: Fix saved searches test mocking and assertions - Fix UserStorage mock in useSavedSearches.test.ts by creating mock with default Promise-returning functions inside jest.mock() factory, then accessing the instance via getMockUserStorageInstance() helper - Fix SavedSearches.test.tsx apply button tests to use correct accessible name "Apply this search" (from tooltip) instead of dynamic aria-label - Fix disabled button assertion to check native disabled attribute instead of relying on aria-disabled which is set inconsistently by Button component - Use findAllByRole for async popup content queries * Alerting: Fix test query for disabled save button Use findByText + closest instead of findByRole to find the disabled "Save current search" button. The Grafana Button component renders with conflicting accessibility attributes (disabled="" + aria-disabled="false") which breaks role-based queries in React Testing Library. * fix(alerting): preserve UserStorage mock reference before clearAllMocks * fix(alerting): add missing test mocks for crypto and console - Mock crypto.randomUUID for Node.js test environment - Add console.error spy to tests expecting storage/parse errors - Add console.warn spy to test expecting validation warnings Fixes jest-fail-on-console failures and crypto.randomUUID TypeError. * fix(alerting): add console.error spy to save failure test * fix(alerting): address PR review feedback for saved searches - Register alertingSavedSearches feature toggle in backend - Extract shared types to SavedSearches.types.ts to fix circular dependencies - Extract sub-components: InlineSaveInput, InlineRenameInput, SavedSearchItem - Remove unused imports (IconButton, Input) and styles from SavedSearches.tsx - Add try/catch for auto-apply default search error handling - Remove maxLength validation and corresponding test * fix(alerting): fix validation error display in saved searches - Fix useEffect dependency array that was immediately clearing validation errors - Remove error from deps so errors only clear when user types, not when set - Run i18n-extract to remove unused error-name-too-long translation key * fix(alerting): address PR review feedback for saved searches - Replace toHaveBeenCalled assertions with UI verification using AppNotificationList - Rename useSavedSearches.test.ts to .tsx for JSX support - Update README documentation to reflect current test patterns - Add test cleanup between E2E tests to prevent data leakage * fix(alerting): remove unused import and fix test wrapper - Remove unused locationService import from RulesFilter.v2.tsx - Add missing bootData spread in useSavedSearches.test.tsx mock - Add createWrapper to renderHook call for user-specific storage key test * fix(alerting): add Redux wrapper to all useSavedSearches hook tests All renderHook calls for useSavedSearches now include the createWrapper() which provides the Redux Provider context required by useAppNotification. * fix(alerting): use regex patterns in MSW handlers for UserStorage tests MSW handlers now use regex patterns to match any namespace and user UID, since UserStorage reads config values from internal imports that aren't affected by jest.mock of @grafana/runtime. * fix(alerting): mock UserStorage directly instead of using MSW Replace MSW HTTP handlers with a direct mock of the UserStorage class. The MSW approach failed because UserStorage evaluates config.namespace at module load time, before jest.mock takes effect, causing the regex patterns to not match the actual request URLs. This follows the same pattern used in useFavoriteDatasources.test.ts. * refactor(alerting): use react-hook-form and Dropdown for saved searches - Migrate InlineRenameInput and InlineSaveInput to react-hook-form - Replace custom PopupCard with Grafana Dropdown component - Use useReducer for centralized dropdown state management - Add stopPropagation handlers to prevent dropdown closing during form interactions - Update tests to use real useSavedSearches hook with mocked UserStorage - Consolidate and simplify saved searches test suite * fix: resolve CI failures in SavedSearches component - Fix TypeScript TS2540 errors by using MutableRefObject type for refs - Fix form submission by using onClick instead of type="submit" on IconButton (IconButton doesn't forward the type prop to the underlying button) - Fix action menu tests by stopping click propagation on ActionMenu wrapper - Fix Escape key handling by focusing the dialog element instead of the potentially-disabled save button * fix(alerting): add navTree to runtime mock in useSavedSearches tests Add empty navTree array to the @grafana/runtime config mock to prevent store initialization crash when buildInitialState() calls .find() on undefined navTree. * fix(alerting): add error handling for auto-apply default search Wrap handleApplySearch call in try-catch to prevent unhandled exceptions when auto-applying the default saved search on navigation. * fix(alerting): prevent saved searches dropdown from closing when clicking action menu The nested Dropdown components caused the outer SavedSearches dropdown to close when clicking on action menu items (Set as default, Rename, Delete). This happened because @floating-ui/react's useDismiss hook detected clicks on the inner Menu (rendered via Portal) as "outside" clicks. Fix: Replace the outer Dropdown with PopupCard and add custom click-outside handling that explicitly excludes portal elements ([role="menu"] and [data-popper-placement]). This matches the pattern used before the Dropdown refactor. Changes: - SavedSearches.tsx: Use PopupCard instead of Dropdown, add click-outside handler - SavedSearchItem.tsx: Add menuPortalRoot prop for action menu positioning - RulesFilter.v2.tsx: Fix double analytics tracking on auto-apply * fix(alerting): auto-apply default saved search on page navigation The default saved search was not being applied when navigating to the Alert rules page. This was caused by a race condition where `isLoading` was `false` on initial render (status was 'not-executed'), causing the auto-apply effect to run before saved searches were loaded. Fix: Include the uninitialized state in the loading check so the effect waits until data is actually loaded before attempting to auto-apply. Also adds tests for the auto-apply functionality. * fix(alerting): align action menu icon and improve saved search tests - Fix vertical alignment of three-dot menu icon in saved search items by adding flex centering to the wrapper div - Add feature toggle setup/teardown in saved searches test suite - Fix location mocking in test for URL search parameter handling * refactor(alerting): improve saved searches validation and organization - Rename SavedSearches.types.ts to savedSearchesSchema.ts - Use react-hook-form's built-in validation instead of manual setError - Change error handling to throw ValidationError instead of returning it - Add type guard isValidationError for safe error checking - Add alphabetical sorting for saved searches (default first) - Replace console.warn/error with logWarning/logError for analytics - Extract helper functions: sortSavedSearches, loadSavedSearchesFromStorage, hasUrlSearchQuery * refactor(alerting): address PR review comments for saved searches (steps 9-12) - Add comprehensive comment explaining useEffect double-render limitation and potential future improvements for default search auto-apply (step 9) - Add test documenting expected behavior when navigating back to alert list after leaving the page - default filter is re-applied (step 10) - Update RulesFilter.v2.test.tsx to use testWithFeatureToggles helper and add MSW UserStorage handlers for future use (step 11) - Update SavedSearches.test.tsx to use render from test/test-utils and byRole selectors for menu items (step 12) * test(alerting): update saved searches tests for refactored API - Update mockSavedSearches order to match sorted output (default first, then alphabetically) - Change validation error tests to use rejects pattern (saveSearch/renameSearch now throw) - Add hasPermission mock to contextSrv for module-level permission check * fix(alerting): fix CI failures for saved searches - Update onRenameComplete type to match throw-based API (Promise) - Run i18n-extract to add missing translation keys * fix(alerting): salvage valid entries when saved searches validation fails Instead of returning an empty array when array validation fails, iterate through each item and keep only the valid entries. This prevents losing all saved searches if a single entry is corrupted. * test(alerting): update test to expect valid entries to be preserved Update the test assertion to match the new behavior where valid saved search entries are preserved when some entries fail validation, rather than discarding all entries. * fix(alerting): eliminate double API request on saved search auto-apply Move saved searches loading and auto-apply logic from RulesFilterV2 to RuleListPage. This ensures the default search filter is applied BEFORE FilterView mounts, preventing double API requests on initial page load. - Load saved searches at RuleListPage level - Gate RuleList rendering until saved searches are loaded - Pass savedSearchesResult as prop to avoid duplicate hook calls - Remove auto-apply tests from RulesFilter.v2.test.tsx (behavior moved) * fix(alerting): mock useSavedSearches in RuleList.v2 tests The useSavedSearches hook triggers async state updates that complete after tests finish, causing React act() warnings. Mock the hook to prevent async operations during tests. * refactor(alerting): migrate saved searches tests to use MSW Address code review feedback by migrating UserStorage tests from jest.mock to MSW-based mocking: - Add MSW helper functions (setAlertingStorageItem, getAlertingStorageItem) to simplify test setup for UserStorage - Migrate useSavedSearches.test.tsx to use MSW handlers instead of jest.mock('@grafana/runtime/internal') - Migrate RulesFilter.v2.test.tsx to use MSW handlers - Update README documentation to accurately reflect how tests use MSW - Add tests for default search auto-apply behavior in RuleListPage - Simplify comments to be concise and accurate * fix(alerting): mock UserStorage directly in useSavedSearches tests The UserStorage class caches its storage spec at the instance level, and the useSavedSearches hook creates the instance at module level. This caused test isolation issues where cached state leaked between tests, making all tests that depended on loading data fail. Fix by mocking UserStorage class directly instead of relying on MSW handlers. This gives each test explicit control over what getItem and setItem return, ensuring proper isolation. Also update persistence assertions to verify mock.setItem calls instead of reading from MSW storage (which the mock bypasses). * refactor(alerting): remove setup helper in SavedSearches tests Replace the `setup()` helper function with direct `render()` calls as suggested in PR review. This makes tests more explicit about what component is being rendered and with what props. * refactor(alerting): extract default search auto-apply into dedicated hook Moves the default saved search auto-apply logic from useSavedSearches into a new useApplyDefaultSearch hook. This improves separation of concerns by keeping useSavedSearches focused on CRUD operations while the new hook handles the page-level auto-apply behavior. Key changes: - Created useApplyDefaultSearch hook with session-based visit tracking - Removed getAutoApplySearch method and user-specific session keys from useSavedSearches - Exported loadDefaultSavedSearch utility for independent default search loading - Simplified test mocks to use loadDefaultSavedSearch instead of full hook mocking - Removed unused savedSearchesResult prop passing through component tree * fix(alerting): improve default search auto-apply timing and test reliability Replace react-use's auto-executing useAsync with internal useAsync hook for better control over when default search is loaded. This prevents race conditions and ensures the async operation only executes when needed. Test improvements: - Add proper session storage cleanup in beforeEach - Use waitFor to handle async operations correctly - Prevent visited flag from affecting subsequent tests - Clear mock call history between tests The internal useAsync hook doesn't auto-execute on mount, allowing us to control exactly when the default search loads based on conditions rather than relying on dependency array triggers. --------- Co-authored-by: Konrad Lalik --- .github/CODEOWNERS | 1 + .../alerting-suite/saved-searches.spec.ts | 271 +++++++++ .../src/types/featureToggles.gen.ts | 4 + pkg/services/featuremgmt/registry.go | 7 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.json | 13 + .../alerting/unified/featureToggles.ts | 5 + .../app/features/alerting/unified/mockApi.ts | 2 + .../unified/mocks/server/all-handlers.ts | 2 + .../mocks/server/handlers/userStorage.ts | 171 ++++++ .../unified/rule-list/RuleList.v2.test.tsx | 146 ++++- .../unified/rule-list/RuleList.v2.tsx | 7 +- .../rule-list/filter/InlineRenameInput.tsx | 146 +++++ .../rule-list/filter/InlineSaveInput.tsx | 139 +++++ .../rule-list/filter/RulesFilter.v2.test.tsx | 117 ++-- .../rule-list/filter/RulesFilter.v2.tsx | 47 +- .../rule-list/filter/SavedSearchItem.tsx | 226 +++++++ .../rule-list/filter/SavedSearches.README.md | 404 +++++++++++++ .../rule-list/filter/SavedSearches.test.tsx | 348 +++++++++++ .../rule-list/filter/SavedSearches.tsx | 556 ++++++++++++++++++ .../rule-list/filter/savedSearchesSchema.ts | 87 +++ .../rule-list/filter/useApplyDefaultSearch.ts | 86 +++ .../filter/useSavedSearches.test.tsx | 400 +++++++++++++ .../rule-list/filter/useSavedSearches.ts | 353 +++++++++++ public/locales/en-US/grafana.json | 29 + 25 files changed, 3508 insertions(+), 60 deletions(-) create mode 100644 e2e-playwright/alerting-suite/saved-searches.spec.ts create mode 100644 public/app/features/alerting/unified/mocks/server/handlers/userStorage.ts create mode 100644 public/app/features/alerting/unified/rule-list/filter/InlineRenameInput.tsx create mode 100644 public/app/features/alerting/unified/rule-list/filter/InlineSaveInput.tsx create mode 100644 public/app/features/alerting/unified/rule-list/filter/SavedSearchItem.tsx create mode 100644 public/app/features/alerting/unified/rule-list/filter/SavedSearches.README.md create mode 100644 public/app/features/alerting/unified/rule-list/filter/SavedSearches.test.tsx create mode 100644 public/app/features/alerting/unified/rule-list/filter/SavedSearches.tsx create mode 100644 public/app/features/alerting/unified/rule-list/filter/savedSearchesSchema.ts create mode 100644 public/app/features/alerting/unified/rule-list/filter/useApplyDefaultSearch.ts create mode 100644 public/app/features/alerting/unified/rule-list/filter/useSavedSearches.test.tsx create mode 100644 public/app/features/alerting/unified/rule-list/filter/useSavedSearches.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4cac8f6dd81..7843dca6c87 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -425,6 +425,7 @@ i18next.config.ts @grafana/grafana-frontend-platform /public/locales/enterprise/i18next.config.ts @grafana/grafana-frontend-platform /public/app/core/internationalization/ @grafana/grafana-frontend-platform /e2e/ @grafana/grafana-frontend-platform +/e2e-playwright/alerting-suite/ @grafana/alerting-frontend /e2e-playwright/cloud-plugins-suite/ @grafana/partner-datasources /e2e-playwright/dashboard-new-layouts/ @grafana/dashboards-squad /e2e-playwright/dashboard-cujs/ @grafana/dashboards-squad diff --git a/e2e-playwright/alerting-suite/saved-searches.spec.ts b/e2e-playwright/alerting-suite/saved-searches.spec.ts new file mode 100644 index 00000000000..28de805a5a1 --- /dev/null +++ b/e2e-playwright/alerting-suite/saved-searches.spec.ts @@ -0,0 +1,271 @@ +import { Page } from '@playwright/test'; + +import { test, expect } from '@grafana/plugin-e2e'; + +/** + * UI selectors for Saved Searches e2e tests. + * Each selector is a function that takes the page and returns a locator. + */ +const ui = { + // Main elements + savedSearchesButton: (page: Page) => page.getByRole('button', { name: /saved searches/i }), + dropdown: (page: Page) => page.getByRole('dialog', { name: /saved searches/i }), + searchInput: (page: Page) => page.getByTestId('search-query-input'), + + // Save functionality + saveButton: (page: Page) => page.getByRole('button', { name: /save current search/i }), + saveConfirmButton: (page: Page) => page.getByRole('button', { name: /^save$/i }), + saveNameInput: (page: Page) => page.getByPlaceholder(/enter a name/i), + + // Action menu + actionsButton: (page: Page) => page.getByRole('button', { name: /actions/i }), + renameMenuItem: (page: Page) => page.getByText(/rename/i), + deleteMenuItem: (page: Page) => page.getByText(/^delete$/i), + setAsDefaultMenuItem: (page: Page) => page.getByText(/set as default/i), + deleteConfirmButton: (page: Page) => page.getByRole('button', { name: /^delete$/i }), + + // Indicators + emptyState: (page: Page) => page.getByText(/no saved searches/i), + defaultIcon: (page: Page) => page.locator('[title="Default search"]'), + duplicateError: (page: Page) => page.getByText(/already exists/i), +}; + +/** + * Helper to clear saved searches storage. + * UserStorage uses localStorage as fallback, so we clear both potential keys. + */ +async function clearSavedSearches(page: Page) { + await page.evaluate(() => { + // Clear localStorage keys that might contain saved searches + // UserStorage stores under 'grafana.userstorage.alerting' pattern + const keysToRemove = Object.keys(localStorage).filter( + (key) => key.includes('alerting') && (key.includes('savedSearches') || key.includes('userstorage')) + ); + keysToRemove.forEach((key) => localStorage.removeItem(key)); + + // Also clear session storage visited flag + const sessionKeysToRemove = Object.keys(sessionStorage).filter((key) => key.includes('alerting')); + sessionKeysToRemove.forEach((key) => sessionStorage.removeItem(key)); + }); +} + +test.describe( + 'Alert Rules - Saved Searches', + { + tag: ['@alerting'], + }, + () => { + test.beforeEach(async ({ page }) => { + // Clear any saved searches from previous tests before navigating + await page.goto('/alerting/list'); + await clearSavedSearches(page); + await page.reload(); + }); + + test.afterEach(async ({ page }) => { + // Clean up saved searches after each test + await clearSavedSearches(page); + }); + + test('should display Saved searches button', async ({ page }) => { + await expect(ui.savedSearchesButton(page)).toBeVisible(); + }); + + test('should open dropdown when clicking Saved searches button', async ({ page }) => { + await ui.savedSearchesButton(page).click(); + + await expect(ui.dropdown(page)).toBeVisible(); + }); + + test('should show empty state when no saved searches exist', async ({ page }) => { + // Storage is cleared in beforeEach, so we should see empty state + await ui.savedSearchesButton(page).click(); + + await expect(ui.emptyState(page)).toBeVisible(); + }); + + test('should enable Save current search button when search query is entered', async ({ page }) => { + // Enter a search query + await ui.searchInput(page).fill('state:firing'); + await ui.searchInput(page).press('Enter'); + + // Open saved searches + await ui.savedSearchesButton(page).click(); + + await expect(ui.saveButton(page)).toBeEnabled(); + }); + + test('should disable Save current search button when search query is empty', async ({ page }) => { + await ui.savedSearchesButton(page).click(); + + await expect(ui.saveButton(page)).toBeDisabled(); + }); + + test('should save a new search', async ({ page }) => { + // Enter a search query + await ui.searchInput(page).fill('state:firing'); + await ui.searchInput(page).press('Enter'); + + // Open saved searches + await ui.savedSearchesButton(page).click(); + + // Click save button + await ui.saveButton(page).click(); + + // Enter name and save + await ui.saveNameInput(page).fill('My Firing Rules'); + await ui.saveConfirmButton(page).click(); + + // Verify the saved search appears in the list + await expect(page.getByText('My Firing Rules')).toBeVisible(); + }); + + test('should show validation error for duplicate name', async ({ page }) => { + // First save a search + await ui.searchInput(page).fill('state:firing'); + await ui.searchInput(page).press('Enter'); + + await ui.savedSearchesButton(page).click(); + + await ui.saveButton(page).click(); + + await ui.saveNameInput(page).fill('Duplicate Test'); + await ui.saveConfirmButton(page).click(); + + // Try to save another with the same name + await ui.saveButton(page).click(); + await ui.saveNameInput(page).fill('Duplicate Test'); + await ui.saveConfirmButton(page).click(); + + // Verify validation error + await expect(ui.duplicateError(page)).toBeVisible(); + }); + + test('should apply a saved search', async ({ page }) => { + // Create a saved search first + await ui.searchInput(page).fill('state:firing'); + await ui.searchInput(page).press('Enter'); + + await ui.savedSearchesButton(page).click(); + + await ui.saveButton(page).click(); + + await ui.saveNameInput(page).fill('Apply Test'); + await ui.saveConfirmButton(page).click(); + + // Clear the search + await ui.searchInput(page).clear(); + await ui.searchInput(page).press('Enter'); + + // Apply the saved search + await ui.savedSearchesButton(page).click(); + await page.getByRole('button', { name: /apply search.*apply test/i }).click(); + + // Verify the search input is updated + await expect(ui.searchInput(page)).toHaveValue('state:firing'); + }); + + test('should rename a saved search', async ({ page }) => { + // Create a saved search + await ui.searchInput(page).fill('state:firing'); + await ui.searchInput(page).press('Enter'); + + await ui.savedSearchesButton(page).click(); + + await ui.saveButton(page).click(); + + await ui.saveNameInput(page).fill('Original Name'); + await ui.saveConfirmButton(page).click(); + + // Open action menu and click rename + await ui.actionsButton(page).click(); + await ui.renameMenuItem(page).click(); + + // Enter new name + const renameInput = page.getByDisplayValue('Original Name'); + await renameInput.clear(); + await renameInput.fill('Renamed Search'); + await page.keyboard.press('Enter'); + + // Verify the name was updated + await expect(page.getByText('Renamed Search')).toBeVisible(); + await expect(page.getByText('Original Name')).not.toBeVisible(); + }); + + test('should delete a saved search', async ({ page }) => { + // Create a saved search + await ui.searchInput(page).fill('state:firing'); + await ui.searchInput(page).press('Enter'); + + await ui.savedSearchesButton(page).click(); + + await ui.saveButton(page).click(); + + await ui.saveNameInput(page).fill('To Delete'); + await ui.saveConfirmButton(page).click(); + + // Verify it was saved + await expect(page.getByText('To Delete')).toBeVisible(); + + // Open action menu and click delete + await ui.actionsButton(page).click(); + await ui.deleteMenuItem(page).click(); + + // Confirm delete + await ui.deleteConfirmButton(page).click(); + + // Verify it was deleted + await expect(page.getByText('To Delete')).not.toBeVisible(); + }); + + test('should set a search as default', async ({ page }) => { + // Create a saved search + await ui.searchInput(page).fill('state:firing'); + await ui.searchInput(page).press('Enter'); + + await ui.savedSearchesButton(page).click(); + + await ui.saveButton(page).click(); + + await ui.saveNameInput(page).fill('Default Test'); + await ui.saveConfirmButton(page).click(); + + // Set as default + await ui.actionsButton(page).click(); + await ui.setAsDefaultMenuItem(page).click(); + + // Verify the star icon appears (indicating default) + await expect(ui.defaultIcon(page)).toBeVisible(); + }); + + test('should close dropdown when pressing Escape', async ({ page }) => { + await ui.savedSearchesButton(page).click(); + + await expect(ui.dropdown(page)).toBeVisible(); + + await page.keyboard.press('Escape'); + + await expect(ui.dropdown(page)).not.toBeVisible(); + }); + + test('should cancel save mode when pressing Escape', async ({ page }) => { + // Enter a search query + await ui.searchInput(page).fill('state:firing'); + await ui.searchInput(page).press('Enter'); + + await ui.savedSearchesButton(page).click(); + + // Start save mode + await ui.saveButton(page).click(); + + await expect(ui.saveNameInput(page)).toBeVisible(); + + // Press Escape to cancel + await page.keyboard.press('Escape'); + + // Verify we're back to list mode + await expect(ui.saveNameInput(page)).not.toBeVisible(); + await expect(ui.saveButton(page)).toBeVisible(); + }); + } +); diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 19a8fbf2c44..981b10dfb1c 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -535,6 +535,10 @@ export interface FeatureToggles { */ alertingListViewV2?: boolean; /** + * Enables saved searches for alert rules list + */ + alertingSavedSearches?: boolean; + /** * Disables the ability to send alerts to an external Alertmanager datasource. */ alertingDisableSendAlertsExternal?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 7e876849dfe..d6f2bcbec2e 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -885,6 +885,13 @@ var ( Owner: grafanaAlertingSquad, FrontendOnly: true, }, + { + Name: "alertingSavedSearches", + Description: "Enables saved searches for alert rules list", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + FrontendOnly: true, + }, { Name: "alertingDisableSendAlertsExternal", Description: "Disables the ability to send alerts to an external Alertmanager datasource.", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 510c05a815b..179568aa0c4 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -122,6 +122,7 @@ suggestedDashboards,experimental,@grafana/sharing-squad,false,false,false dashboardTemplates,preview,@grafana/sharing-squad,false,false,false logsExploreTableDefaultVisualization,experimental,@grafana/observability-logs,false,false,true alertingListViewV2,privatePreview,@grafana/alerting-squad,false,false,true +alertingSavedSearches,experimental,@grafana/alerting-squad,false,false,true alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false preserveDashboardStateWhenNavigating,experimental,@grafana/dashboards-squad,false,false,false alertingCentralAlertHistory,experimental,@grafana/alerting-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 0db4a887a6a..42922ecf82d 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -498,6 +498,19 @@ "codeowner": "@grafana/alerting-squad" } }, + { + "metadata": { + "name": "alertingSavedSearches", + "resourceVersion": "1765453147546", + "creationTimestamp": "2025-12-11T11:39:07Z" + }, + "spec": { + "description": "Enables saved searches for alert rules list", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "frontend": true + } + }, { "metadata": { "name": "alertingTriage", diff --git a/public/app/features/alerting/unified/featureToggles.ts b/public/app/features/alerting/unified/featureToggles.ts index 7ca1747a8c5..15fa8e11fd8 100644 --- a/public/app/features/alerting/unified/featureToggles.ts +++ b/public/app/features/alerting/unified/featureToggles.ts @@ -26,3 +26,8 @@ export const shouldUseBackendFilters = () => config.featureToggles.alertingUIUse export const shouldUseFullyCompatibleBackendFilters = () => config.featureToggles.alertingUIUseFullyCompatBackendFilters ?? false; + +/** + * Saved searches feature - allows users to save and apply search queries on the Alert Rules page. + */ +export const shouldUseSavedSearches = () => config.featureToggles.alertingSavedSearches ?? false; diff --git a/public/app/features/alerting/unified/mockApi.ts b/public/app/features/alerting/unified/mockApi.ts index 614c8235dd0..67053ade5f6 100644 --- a/public/app/features/alerting/unified/mockApi.ts +++ b/public/app/features/alerting/unified/mockApi.ts @@ -9,6 +9,7 @@ import { setupAlertmanagerStatusMapDefaultState, } from 'app/features/alerting/unified/mocks/server/entities/alertmanagers'; import { resetRoutingTreeMap } from 'app/features/alerting/unified/mocks/server/entities/k8s/routingtrees'; +import { resetUserStorage } from 'app/features/alerting/unified/mocks/server/handlers/userStorage'; import { DashboardDTO } from 'app/types/dashboard'; import { FolderDTO } from 'app/types/folders'; import { @@ -256,6 +257,7 @@ export function setupMswServer() { setupAlertmanagerConfigMapDefaultState(); setupAlertmanagerStatusMapDefaultState(); resetRoutingTreeMap(); + resetUserStorage(); }); return server; diff --git a/public/app/features/alerting/unified/mocks/server/all-handlers.ts b/public/app/features/alerting/unified/mocks/server/all-handlers.ts index 4b0b0ac7249..c3a9db4ac5c 100644 --- a/public/app/features/alerting/unified/mocks/server/all-handlers.ts +++ b/public/app/features/alerting/unified/mocks/server/all-handlers.ts @@ -19,6 +19,7 @@ import allPluginHandlers from 'app/features/alerting/unified/mocks/server/handle import provisioningHandlers from 'app/features/alerting/unified/mocks/server/handlers/provisioning'; import searchHandlers from 'app/features/alerting/unified/mocks/server/handlers/search'; import silenceHandlers from 'app/features/alerting/unified/mocks/server/handlers/silences'; +import userStorageHandlers from 'app/features/alerting/unified/mocks/server/handlers/userStorage'; /** * All alerting-specific handlers that are required across tests @@ -55,6 +56,7 @@ const allHandlers = [ ...datasourcesHandlers, ...evalHandlers, ...pluginsHandlers, + ...userStorageHandlers, ]; export default allHandlers; diff --git a/public/app/features/alerting/unified/mocks/server/handlers/userStorage.ts b/public/app/features/alerting/unified/mocks/server/handlers/userStorage.ts new file mode 100644 index 00000000000..b2a162ea898 --- /dev/null +++ b/public/app/features/alerting/unified/mocks/server/handlers/userStorage.ts @@ -0,0 +1,171 @@ +import { HttpResponse, http } from 'msw'; + +import { config } from '@grafana/runtime'; + +/** + * UserStorage spec type matching the backend API response. + */ +interface UserStorageSpec { + data: { [key: string]: string }; +} + +/** + * In-memory storage for UserStorage mock data. + * This allows tests to set up and verify storage state. + */ +let userStorageData: Record = {}; + +/** + * Get the base URL for UserStorage API. + * Uses config.namespace which defaults to 'default' in tests. + */ +const getBaseUrl = () => `/apis/userstorage.grafana.app/v0alpha1/namespaces/${config.namespace}/user-storage`; + +/** + * Reset the in-memory storage. Call this in beforeEach to ensure clean test state. + */ +export function resetUserStorage(): void { + userStorageData = {}; +} + +/** + * Get the resource name for a given service, matching how UserStorage constructs it. + * This uses config.bootData.user to determine the user identifier. + * + * @param service - The service name (e.g., 'alerting') + * @returns The resource name in format `{service}:{userUID}` + * + * @example + * // In a test with config.bootData.user = { uid: '', id: 123 } + * const resourceName = getResourceName('alerting'); // 'alerting:123' + * + * // In a test with config.bootData.user = { uid: 'abc-123', id: 456 } + * const resourceName = getResourceName('alerting'); // 'alerting:abc-123' + */ +export function getResourceName(service: string): string { + const user = config.bootData?.user; + const userUID = user?.uid === '' || !user?.uid ? String(user?.id ?? 'anonymous') : user.uid; + return `${service}:${userUID}`; +} + +/** + * Convenience constant for the alerting service name. + * Use with getResourceName('alerting') or ALERTING_SERVICE directly. + */ +export const ALERTING_SERVICE = 'alerting'; + +/** + * Set up initial data in the UserStorage mock. + * @param resourceName - The resource name (e.g., 'alerting:123'). Use getResourceName() to construct this. + * @param key - The storage key + * @param value - The value to store + */ +export function setUserStorageItem(resourceName: string, key: string, value: string): void { + if (!userStorageData[resourceName]) { + userStorageData[resourceName] = { data: {} }; + } + userStorageData[resourceName].data[key] = value; +} + +/** + * Convenience function to set alerting storage items using the current config's user. + * This automatically constructs the resource name from config.bootData.user. + * + * @param key - The storage key (e.g., 'savedSearches') + * @param value - The value to store (will be stored as-is, caller should JSON.stringify if needed) + * + * @example + * // Set up saved searches for testing + * setAlertingStorageItem('savedSearches', JSON.stringify([{ id: '1', name: 'Test', query: 'state:firing', isDefault: false, createdAt: Date.now() }])); + */ +export function setAlertingStorageItem(key: string, value: string): void { + const resourceName = getResourceName(ALERTING_SERVICE); + setUserStorageItem(resourceName, key, value); +} + +/** + * Convenience function to get alerting storage items using the current config's user. + * + * @param key - The storage key (e.g., 'savedSearches') + * @returns The stored value or null if not found + */ +export function getAlertingStorageItem(key: string): string | null { + const resourceName = getResourceName(ALERTING_SERVICE); + return getUserStorageItem(resourceName, key); +} + +/** + * Get data from the UserStorage mock. + * @param resourceName - The resource name (e.g., 'alerting:123') + * @param key - The storage key + * @returns The stored value or null if not found + */ +export function getUserStorageItem(resourceName: string, key: string): string | null { + return userStorageData[resourceName]?.data[key] ?? null; +} + +/** + * Get the full storage spec for a resource. + * @param resourceName - The resource name + */ +export function getUserStorageSpec(resourceName: string): UserStorageSpec | null { + return userStorageData[resourceName] ?? null; +} + +/** + * MSW handler for GET UserStorage (retrieve stored data) + */ +const getUserStorageHandler = () => + http.get<{ resourceName: string }>(`${getBaseUrl()}/:resourceName`, ({ params }) => { + const spec = userStorageData[params.resourceName]; + + if (!spec) { + return HttpResponse.json({ message: 'Not found' }, { status: 404 }); + } + + return HttpResponse.json({ spec }); + }); + +/** + * MSW handler for POST UserStorage (create new storage) + */ +const createUserStorageHandler = () => + http.post(getBaseUrl(), async ({ request }) => { + const body = (await request.json()) as { + metadata: { name: string; labels: { user: string; service: string } }; + spec: UserStorageSpec; + }; + + const resourceName = body.metadata.name; + userStorageData[resourceName] = body.spec; + + return HttpResponse.json({ spec: body.spec }, { status: 201 }); + }); + +/** + * MSW handler for PATCH UserStorage (update existing storage) + */ +const patchUserStorageHandler = () => + http.patch<{ resourceName: string }>(`${getBaseUrl()}/:resourceName`, async ({ params, request }) => { + const body = (await request.json()) as { spec: UserStorageSpec }; + const resourceName = params.resourceName; + + if (!userStorageData[resourceName]) { + userStorageData[resourceName] = { data: {} }; + } + + // Merge the new data with existing data + userStorageData[resourceName].data = { + ...userStorageData[resourceName].data, + ...body.spec.data, + }; + + return HttpResponse.json({ spec: userStorageData[resourceName] }); + }); + +/** + * All UserStorage MSW handlers + */ +const handlers = [getUserStorageHandler(), createUserStorageHandler(), patchUserStorageHandler()]; + +export default handlers; diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx index d4c445a2772..7985791cfa2 100644 --- a/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx @@ -1,5 +1,5 @@ import { HttpResponse } from 'msw'; -import { render, testWithFeatureToggles } from 'test/test-utils'; +import { render, testWithFeatureToggles, waitFor } from 'test/test-utils'; import { byRole, byTestId } from 'testing-library-selector'; import { OrgRole } from '@grafana/data'; @@ -12,7 +12,8 @@ import { setGrafanaRuleGroupExportResolver } from '../mocks/server/configure'; import { alertingFactory } from '../mocks/server/db'; import { RulesFilter } from '../search/rulesSearchParser'; -import RuleList, { RuleListActions } from './RuleList.v2'; +import RuleListPage, { RuleListActions } from './RuleList.v2'; +import { loadDefaultSavedSearch } from './filter/useSavedSearches'; // This tests only checks if proper components are rendered, so we mock them // Both FilterView and GroupedView are tested in their own tests @@ -24,6 +25,30 @@ jest.mock('./GroupedView', () => ({ GroupedView: () =>
Grouped View
, })); +jest.mock('./filter/useSavedSearches', () => ({ + ...jest.requireActual('./filter/useSavedSearches'), + loadDefaultSavedSearch: jest.fn(), + useSavedSearches: jest.fn(() => ({ + savedSearches: [], + isLoading: false, + saveSearch: jest.fn(), + renameSearch: jest.fn(), + deleteSearch: jest.fn(), + setDefaultSearch: jest.fn(), + })), +})); + +const loadDefaultSavedSearchMock = loadDefaultSavedSearch as jest.MockedFunction; + +beforeEach(() => { + loadDefaultSavedSearchMock.mockResolvedValue(null); + // Clear session storage to ensure clean state for each test + // This prevents the "visited" flag from affecting subsequent tests + sessionStorage.clear(); + // Set the visited flag for non-default-search tests to prevent the hook from trying to load + sessionStorage.setItem('grafana.alerting.ruleList.visited', 'true'); +}); + const ui = { filterView: byTestId('filter-view'), groupedView: byTestId('grouped-view'), @@ -45,16 +70,16 @@ setupMswServer(); alertingFactory.dataSource.build({ name: 'Mimir', uid: 'mimir' }); alertingFactory.dataSource.build({ name: 'Prometheus', uid: 'prometheus' }); -describe('RuleList v2', () => { +describe('RuleListPage v2', () => { it('should show grouped view by default', () => { - render(); + render(); expect(ui.groupedView.get()).toBeInTheDocument(); expect(ui.filterView.query()).not.toBeInTheDocument(); }); it('should show grouped view when invalid view parameter is provided', () => { - render(, { + render(, { historyOptions: { initialEntries: ['/?view=invalid'], }, @@ -65,35 +90,35 @@ describe('RuleList v2', () => { }); it('should show list view when "view=list" URL parameter is present', () => { - render(, { historyOptions: { initialEntries: ['/?view=list'] } }); + render(, { historyOptions: { initialEntries: ['/?view=list'] } }); expect(ui.filterView.get()).toBeInTheDocument(); expect(ui.groupedView.query()).not.toBeInTheDocument(); }); it('should show grouped view when only group filter is applied', () => { - render(, { historyOptions: { initialEntries: ['/?search=group:cpu-usage'] } }); + render(, { historyOptions: { initialEntries: ['/?search=group:cpu-usage'] } }); expect(ui.groupedView.get()).toBeInTheDocument(); expect(ui.filterView.query()).not.toBeInTheDocument(); }); it('should show grouped view when only namespace filter is applied', () => { - render(, { historyOptions: { initialEntries: ['/?search=namespace:global'] } }); + render(, { historyOptions: { initialEntries: ['/?search=namespace:global'] } }); expect(ui.groupedView.get()).toBeInTheDocument(); expect(ui.filterView.query()).not.toBeInTheDocument(); }); it('should show grouped view when both group and namespace filters are applied', () => { - render(, { historyOptions: { initialEntries: ['/?search=group:cpu-usage namespace:global'] } }); + render(, { historyOptions: { initialEntries: ['/?search=group:cpu-usage namespace:global'] } }); expect(ui.groupedView.get()).toBeInTheDocument(); expect(ui.filterView.query()).not.toBeInTheDocument(); }); it('should show list view when group and namespace filters are combined with other filter types', () => { - render(, { + render(, { historyOptions: { initialEntries: ['/?search=group:cpu-usage namespace:global state:firing'] }, }); @@ -102,14 +127,14 @@ describe('RuleList v2', () => { }); it('should show grouped view when view parameter is empty', () => { - render(, { historyOptions: { initialEntries: ['/?view='] } }); + render(, { historyOptions: { initialEntries: ['/?view='] } }); expect(ui.groupedView.get()).toBeInTheDocument(); expect(ui.filterView.query()).not.toBeInTheDocument(); }); it('should show grouped view when search parameter is empty', () => { - render(, { historyOptions: { initialEntries: ['/?search='] } }); + render(, { historyOptions: { initialEntries: ['/?search='] } }); expect(ui.groupedView.get()).toBeInTheDocument(); expect(ui.filterView.query()).not.toBeInTheDocument(); @@ -125,28 +150,28 @@ describe('RuleList v2', () => { { filterType: 'ruleHealth', searchQuery: 'health:error' }, { filterType: 'contactPoint', searchQuery: 'contactPoint:slack' }, ])('should show list view when %s filter is applied', ({ filterType, searchQuery }) => { - render(, { historyOptions: { initialEntries: [`/?search=${encodeURIComponent(searchQuery)}`] } }); + render(, { historyOptions: { initialEntries: [`/?search=${encodeURIComponent(searchQuery)}`] } }); expect(ui.filterView.get()).toBeInTheDocument(); expect(ui.groupedView.query()).not.toBeInTheDocument(); }); it('should show list view when "view=list" URL parameter is present with group filter', () => { - render(, { historyOptions: { initialEntries: ['/?view=list&search=group:cpu-usage'] } }); + render(, { historyOptions: { initialEntries: ['/?view=list&search=group:cpu-usage'] } }); expect(ui.filterView.get()).toBeInTheDocument(); expect(ui.groupedView.query()).not.toBeInTheDocument(); }); it('should show list view when "view=list" URL parameter is present with namespace filter', () => { - render(, { historyOptions: { initialEntries: ['/?view=list&search=namespace:global'] } }); + render(, { historyOptions: { initialEntries: ['/?view=list&search=namespace:global'] } }); expect(ui.filterView.get()).toBeInTheDocument(); expect(ui.groupedView.query()).not.toBeInTheDocument(); }); it('should show list view when "view=list" URL parameter is present with both group and namespace filters', () => { - render(, { + render(, { historyOptions: { initialEntries: ['/?view=list&search=group:cpu-usage namespace:global'] }, }); @@ -342,10 +367,10 @@ describe('RuleListActions', () => { }); }); -describe('RuleList v2 - View switching', () => { +describe('RuleListPage v2 - View switching', () => { it('should preserve both group and namespace filters when switching from list view to grouped view', async () => { // Start with list view and both group and namespace filters - const { user } = render(, { + const { user } = render(, { historyOptions: { initialEntries: ['/?view=list&search=group:cpu-usage namespace:global'] }, }); expect(ui.filterView.get()).toBeInTheDocument(); @@ -365,7 +390,7 @@ describe('RuleList v2 - View switching', () => { it('should clear all filters when switching from list view to grouped view with group, namespace and other filters', async () => { // Start with list view with all types of filters - const { user } = render(, { + const { user } = render(, { historyOptions: { initialEntries: ['/?view=list&search=group:cpu-usage namespace:global state:firing rule:"test"'], }, @@ -385,3 +410,86 @@ describe('RuleList v2 - View switching', () => { expect(ui.modeSelector.list.query()).not.toBeChecked(); }); }); +describe('RuleListPage v2 - Default search auto-apply', () => { + // These tests verify that the default search is applied at the page level, + // BEFORE child components mount, preventing double API requests. + + testWithFeatureToggles({ enable: ['alertingListViewV2', 'alertingSavedSearches'] }); + + beforeEach(() => { + // Clear the visited flag so the hook detects this as a first visit + sessionStorage.removeItem('grafana.alerting.ruleList.visited'); + // Clear mock call history between tests + loadDefaultSavedSearchMock.mockClear(); + }); + + it('should apply default search before rendering child components', async () => { + const mockDefaultSearch = { + id: '1', + name: 'My Default', + query: 'state:firing', + isDefault: true, + createdAt: Date.now(), + }; + + // Mock loadDefaultSavedSearch to return a default search + loadDefaultSavedSearchMock.mockResolvedValue(mockDefaultSearch); + + render(); + + // Wait for loadDefaultSavedSearch to be called + await waitFor(() => { + expect(loadDefaultSavedSearchMock).toHaveBeenCalled(); + }); + + // Wait for the filter view to render with the applied search + await waitFor(() => { + expect(ui.filterView.get()).toBeInTheDocument(); + }); + + // Verify the search input shows the applied search query + expect(ui.searchInput.get()).toHaveValue('state:firing'); + }); + + it('should not apply default search when URL already has search parameter', async () => { + const mockDefaultSearch = { + id: '1', + name: 'My Default', + query: 'state:firing', + isDefault: true, + createdAt: Date.now(), + }; + + // loadDefaultSavedSearch should not be called when URL has search param + loadDefaultSavedSearchMock.mockResolvedValue(mockDefaultSearch); + + render(, { + historyOptions: { initialEntries: ['/?search=label:team=backend'] }, + }); + + // Wait for the component to render + await waitFor(() => { + expect(ui.searchInput.get()).toBeInTheDocument(); + }); + + // Should show the URL's search, not the default + expect(ui.searchInput.get()).toHaveValue('label:team=backend'); + + // Verify loadDefaultSavedSearch was not called because filters are already active + // The hook should not execute at all when hasActiveFilters is true + expect(loadDefaultSavedSearchMock).not.toHaveBeenCalled(); + }); + + it('should render normally when no default search exists', async () => { + loadDefaultSavedSearchMock.mockResolvedValue(null); + + render(); + + // Wait for the component to render after checking for default search + await waitFor(() => { + expect(ui.groupedView.get()).toBeInTheDocument(); + }); + + expect(ui.searchInput.get()).toHaveValue(''); + }); +}); diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx index e828b00fa16..284bd6ad757 100644 --- a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx @@ -18,6 +18,7 @@ import { FilterView } from './FilterView'; import { GroupedView } from './GroupedView'; import { RuleListPageTitle } from './RuleListPageTitle'; import RulesFilter from './filter/RulesFilter'; +import { useApplyDefaultSearch } from './filter/useApplyDefaultSearch'; function RuleList() { const { filterState } = useRulesFilter(); @@ -117,14 +118,16 @@ export function RuleListActions() { } export default function RuleListPage() { + const { isApplying } = useApplyDefaultSearch(); + return ( } - isLoading={false} + isLoading={isApplying} actions={} > - + {!isApplying && } ); } diff --git a/public/app/features/alerting/unified/rule-list/filter/InlineRenameInput.tsx b/public/app/features/alerting/unified/rule-list/filter/InlineRenameInput.tsx new file mode 100644 index 00000000000..ae2db9e7264 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/filter/InlineRenameInput.tsx @@ -0,0 +1,146 @@ +import { css } from '@emotion/css'; +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { Box, IconButton, Input, Stack, Text, useStyles2 } from '@grafana/ui'; + +import { useAppNotification } from '../../../../../core/copy/appNotification'; + +import { SavedSearch, isValidationError, validateSearchName } from './savedSearchesSchema'; + +// ============================================================================ +// Inline Rename Input (compact input with icon buttons for renaming) +// ============================================================================ + +export interface InlineRenameInputProps { + initialValue: string; + /** Callback to save the renamed search. Throws ValidationError on validation failure. */ + onSave: (name: string) => Promise; + onCancel: () => void; + savedSearches: SavedSearch[]; + excludeId: string; +} + +interface FormValues { + name: string; +} + +export function InlineRenameInput({ + initialValue, + onSave, + onCancel, + savedSearches, + excludeId, +}: InlineRenameInputProps) { + const styles = useStyles2(getStyles); + const notifyApp = useAppNotification(); + + const { + register, + handleSubmit, + setFocus, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { name: initialValue }, + }); + + // Focus and select input on mount using react-hook-form's setFocus + useEffect(() => { + setFocus('name', { shouldSelect: true }); + }, [setFocus]); + + const onSubmit = async (data: FormValues) => { + try { + await onSave(data.name.trim()); + } catch (error) { + // Check if it's a validation error (has field and message) + if (isValidationError(error)) { + // Validation errors are shown inline in the form + // This is handled by react-hook-form validation, but we keep this + // as a fallback for server-side validation errors + return; + } + // For generic save operation errors, show a notification + notifyApp.error( + t('alerting.saved-searches.error-rename-title', 'Failed to rename'), + t('alerting.saved-searches.error-rename-description', 'Your changes could not be saved. Please try again.') + ); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onCancel(); + } + }; + + return ( + +
+ + {/* Input area - flex=1 like the name area in list items */} + + { + const error = validateSearchName(value, savedSearches, excludeId); + return error ?? true; + }, + })} + onKeyDown={handleKeyDown} + placeholder={t('alerting.saved-searches.name-placeholder', 'Enter a name...')} + invalid={!!errors.name} + disabled={isSubmitting} + /> + + + {/* X icon - cancel */} + + + {/* Check icon - confirm rename */} + {/* Note: IconButton doesn't forward type="submit", so we use onClick with handleSubmit */} + + + + {errors.name?.message && ( + + {errors.name.message} + + )} +
+ ); +} + +// ============================================================================ +// Styles +// ============================================================================ + +function getStyles(theme: GrafanaTheme2) { + return { + successIcon: css({ + color: theme.colors.success.main, + }), + }; +} diff --git a/public/app/features/alerting/unified/rule-list/filter/InlineSaveInput.tsx b/public/app/features/alerting/unified/rule-list/filter/InlineSaveInput.tsx new file mode 100644 index 00000000000..9fd11151cd0 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/filter/InlineSaveInput.tsx @@ -0,0 +1,139 @@ +import { css } from '@emotion/css'; +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { Box, IconButton, Input, Stack, Text, useStyles2 } from '@grafana/ui'; + +import { useAppNotification } from '../../../../../core/copy/appNotification'; + +import { SavedSearch, isValidationError, validateSearchName } from './savedSearchesSchema'; + +// ============================================================================ +// Inline Save Input (compact input with icon buttons) +// ============================================================================ + +export interface InlineSaveInputProps { + /** Callback to save the search. Throws ValidationError on validation failure. */ + onSave: (name: string) => Promise; + onCancel: () => void; + savedSearches: SavedSearch[]; +} + +interface FormValues { + name: string; +} + +export function InlineSaveInput({ onSave, onCancel, savedSearches }: InlineSaveInputProps) { + const styles = useStyles2(getStyles); + const notifyApp = useAppNotification(); + + const { + register, + handleSubmit, + setFocus, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { name: '' }, + }); + + // Focus input on mount using react-hook-form's setFocus + useEffect(() => { + setFocus('name'); + }, [setFocus]); + + const onSubmit = async (data: FormValues) => { + try { + await onSave(data.name.trim()); + } catch (error) { + // Check if it's a validation error (has field and message) + if (isValidationError(error)) { + // Validation errors are shown inline in the form + // This is handled by react-hook-form validation, but we keep this + // as a fallback for server-side validation errors + return; + } + // For generic save operation errors, show a notification + notifyApp.error( + t('alerting.saved-searches.error-save-title', 'Failed to save'), + t('alerting.saved-searches.error-save-description', 'Your changes could not be saved. Please try again.') + ); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onCancel(); + } + }; + + return ( + + {/* Match exact structure of SavedSearchItem: [flex-1 content] [icon] [icon] with gap={1} */} +
+ + {/* Input area - flex=1 like the name area in list items */} + + { + const error = validateSearchName(value, savedSearches); + return error ?? true; + }, + })} + onKeyDown={handleKeyDown} + placeholder={t('alerting.saved-searches.name-placeholder', 'Enter a name...')} + invalid={!!errors.name} + disabled={isSubmitting} + /> + + + {/* X icon - aligned with magnifying glass */} + + + {/* Check icon - aligned with action menu */} + {/* Note: IconButton doesn't forward type="submit", so we use onClick with handleSubmit */} + + + + {errors.name?.message && ( + + {errors.name.message} + + )} +
+ ); +} + +// ============================================================================ +// Styles +// ============================================================================ + +function getStyles(theme: GrafanaTheme2) { + return { + successIcon: css({ + color: theme.colors.success.main, + }), + }; +} diff --git a/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.test.tsx b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.test.tsx index a8f6c91cbc3..8da5901a4b8 100644 --- a/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.test.tsx +++ b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.test.tsx @@ -1,8 +1,8 @@ -import { render, screen } from 'test/test-utils'; +import { render, screen, testWithFeatureToggles, waitFor } from 'test/test-utils'; import { byRole, byTestId } from 'testing-library-selector'; import { ComponentTypeWithExtensionMeta, PluginExtensionComponentMeta, PluginExtensionTypes } from '@grafana/data'; -import { config, locationService, setPluginComponentsHook } from '@grafana/runtime'; +import { locationService, setPluginComponentsHook } from '@grafana/runtime'; import { setupMswServer } from 'app/features/alerting/unified/mockApi'; import { grantUserPermissions } from 'app/features/alerting/unified/mocks'; import { AccessControlAction } from 'app/types/accessControl'; @@ -15,6 +15,51 @@ import { setupPluginsExtensionsHook } from '../../testSetup/plugins'; import RulesFilter from './RulesFilter'; +// Mock config for UserStorage (namespace and user must be set before UserStorage module loads) +// This allows the real UserStorage class to work with MSW handlers +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + reportInteraction: jest.fn(), // Silence analytics calls from useSavedSearches + getDataSourceSrv: () => ({ + getList: jest.fn().mockReturnValue([ + { name: 'Prometheus', uid: 'prometheus-uid' }, + { name: 'Loki', uid: 'loki-uid' }, + ]), + }), + config: { + ...jest.requireActual('@grafana/runtime').config, + namespace: 'default', + bootData: { + ...jest.requireActual('@grafana/runtime').config.bootData, + navTree: [], + user: { + uid: 'test-user-123', + id: 123, + isSignedIn: true, + }, + }, + }, +})); + +// Set up contextSrv.user.id for useSavedSearches session storage key. +// The hook uses this ID to create a per-user session storage key. +// Note: hasPermission must be mocked here because RulesFilter.v1.tsx calls it at module load time, +// before grantUserPermissions can set up the spy. grantUserPermissions still works for runtime checks. +jest.mock('app/core/services/context_srv', () => { + const actual = jest.requireActual('app/core/services/context_srv'); + return { + ...actual, + contextSrv: { + ...actual.contextSrv, + user: { + ...actual.contextSrv.user, + id: 123, + }, + hasPermission: jest.fn().mockReturnValue(true), + }, + }; +}); + // Grant permission before importing the component since permission check happens at module level grantUserPermissions([AccessControlAction.AlertingReceiversRead]); // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -46,6 +91,7 @@ jest.mock('../../hooks/useFilteredRules', () => ({ const useRulesFilterMock = useRulesFilter as jest.MockedFunction; +// Set up MSW server with UserStorage handlers setupMswServer(); jest.spyOn(analytics, 'trackFilterButtonClick'); @@ -54,16 +100,6 @@ jest.spyOn(analytics, 'trackFilterButtonClearClick'); jest.spyOn(analytics, 'trackAlertRuleFilterEvent'); jest.spyOn(analytics, 'trackRulesSearchInputCleared'); -jest.mock('@grafana/runtime', () => ({ - ...jest.requireActual('@grafana/runtime'), - getDataSourceSrv: () => ({ - getList: jest.fn().mockReturnValue([ - { name: 'Prometheus', uid: 'prometheus-uid' }, - { name: 'Loki', uid: 'loki-uid' }, - ]), - }), -})); - jest.mock('../../components/rules/MultipleDataSourcePicker', () => { const original = jest.requireActual('../../components/rules/MultipleDataSourcePicker'); return { @@ -113,6 +149,8 @@ const ui = { beforeEach(() => { locationService.replace({ search: '' }); jest.clearAllMocks(); + sessionStorage.clear(); + localStorage.clear(); mockFilterState = { ruleName: '', @@ -150,45 +188,46 @@ beforeEach(() => { }); describe('RulesFilter Feature Flag', () => { - const originalFeatureToggle = config.featureToggles.alertingFilterV2; + describe('with alertingFilterV2 enabled', () => { + testWithFeatureToggles({ enable: ['alertingFilterV2'] }); - afterEach(() => { - config.featureToggles.alertingFilterV2 = originalFeatureToggle; + it('Should render RulesFilterV2 when alertingFilterV2 feature flag is enabled', async () => { + render(); + + // Wait for suspense to resolve and check that the V2 filter button is present + await screen.findByRole('button', { name: 'Filter' }); + expect(ui.filterButton.get()).toBeInTheDocument(); + expect(ui.searchInput.get()).toBeInTheDocument(); + }); }); - it('Should render RulesFilterV2 when alertingFilterV2 feature flag is enabled', async () => { - config.featureToggles.alertingFilterV2 = true; + describe('with alertingFilterV2 disabled', () => { + testWithFeatureToggles({ disable: ['alertingFilterV2'] }); - render(); + it('Should render RulesFilterV1 when alertingFilterV2 feature flag is disabled', async () => { + render(); - // Wait for suspense to resolve and check that the V2 filter button is present - await screen.findByRole('button', { name: 'Filter' }); - expect(ui.filterButton.get()).toBeInTheDocument(); - expect(ui.searchInput.get()).toBeInTheDocument(); - }); + // Wait for suspense to resolve and check V1 structure + await screen.findByText('Search'); - it('Should render RulesFilterV1 when alertingFilterV2 feature flag is disabled', async () => { - config.featureToggles.alertingFilterV2 = false; + // V1 has search input but no V2-style filter button + expect(ui.searchInput.get()).toBeInTheDocument(); + expect(ui.filterButton.query()).not.toBeInTheDocument(); - render(); - - // Wait for suspense to resolve and check V1 structure - await screen.findByText('Search'); - - // V1 has search input but no V2-style filter button - expect(ui.searchInput.get()).toBeInTheDocument(); - expect(ui.filterButton.query()).not.toBeInTheDocument(); - - // V1 has a help icon next to the search input - expect(screen.getByText('Search')).toBeInTheDocument(); + // V1 has a help icon next to the search input + expect(screen.getByText('Search')).toBeInTheDocument(); + }); }); }); describe('RulesFilterV2', () => { - it('Should render component without crashing', () => { + it('Should render component without crashing', async () => { render(); - expect(ui.searchInput.get()).toBeInTheDocument(); + // Wait for async hook operations (useSavedSearches) to complete + await waitFor(() => { + expect(ui.searchInput.get()).toBeInTheDocument(); + }); expect(ui.filterButton.get()).toBeInTheDocument(); }); @@ -429,4 +468,6 @@ describe('RulesFilterV2', () => { expect(analytics.trackFilterButtonClick).toHaveBeenCalledTimes(1); }); }); + + // Auto-apply of default search is tested in RuleList.v2.test.tsx (behavior is in RuleListPage) }); diff --git a/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.tsx b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.tsx index 0091cdddec9..eb2e368ad80 100644 --- a/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.tsx +++ b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.tsx @@ -39,10 +39,14 @@ import { useLabelOptions, useNamespaceAndGroupOptions, } from '../../components/rules/Filter/useRuleFilterAutocomplete'; +import { shouldUseSavedSearches } from '../../featureToggles'; import { useRulesFilter } from '../../hooks/useFilteredRules'; import { RuleHealth, RuleSource, getSearchFilterFromQuery } from '../../search/rulesSearchParser'; import { RulesFilterProps } from './RulesFilter'; +import { SavedSearches } from './SavedSearches'; +import { SavedSearch } from './savedSearchesSchema'; +import { trackSavedSearchApplied, useSavedSearches } from './useSavedSearches'; import { emptyAdvancedFilters, formAdvancedFiltersToRuleFilter, @@ -86,6 +90,19 @@ export default function RulesFilter({ viewMode, onViewModeChange }: RulesFilterP const popupRef = useRef(null); const { pluginsFilterEnabled } = usePluginsFilterStatus(); + // Feature toggle for saved searches + const savedSearchesEnabled = shouldUseSavedSearches(); + + // Saved searches hook with UserStorage persistence + const { + savedSearches, + isLoading: savedSearchesLoading, + saveSearch, + renameSearch, + deleteSearch, + setDefaultSearch, + } = useSavedSearches(); + // this form will managed the search query string, which is updated either by the user typing in the input or by the advanced filters const { control, setValue, handleSubmit } = useForm({ defaultValues: { @@ -97,6 +114,20 @@ export default function RulesFilter({ viewMode, onViewModeChange }: RulesFilterP setValue('query', searchQuery); }, [searchQuery, setValue]); + // Apply saved search - triggers filtering (which updates search input and URL) + const handleApplySearch = useCallback( + (search: SavedSearch) => { + const parsedFilter = getSearchFilterFromQuery(search.query); + updateFilters(parsedFilter); + + // Track analytics + trackSavedSearchApplied(search); + }, + [updateFilters] + ); + + // Auto-apply of default search is handled in RuleListPage (before FilterView mounts) + const submitHandler: SubmitHandler = (values: SearchQueryForm) => { const parsedFilter = getSearchFilterFromQuery(values.query); trackAlertRuleFilterEvent({ filterMethod: 'search-input', filter: parsedFilter, filterVariant: 'v2' }); @@ -240,7 +271,21 @@ export default function RulesFilter({ viewMode, onViewModeChange }: RulesFilterP {filterButtonLabel} - + {savedSearchesEnabled && ( + + )} + + +
diff --git a/public/app/features/alerting/unified/rule-list/filter/SavedSearchItem.tsx b/public/app/features/alerting/unified/rule-list/filter/SavedSearchItem.tsx new file mode 100644 index 00000000000..0fb92622b2d --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/filter/SavedSearchItem.tsx @@ -0,0 +1,226 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { Dropdown, Icon, IconButton, Menu, Stack, Text, useStyles2 } from '@grafana/ui'; + +import { InlineRenameInput } from './InlineRenameInput'; +import { SavedSearch } from './savedSearchesSchema'; + +// ============================================================================ +// Saved Search Item +// ============================================================================ + +export interface SavedSearchItemProps { + search: SavedSearch; + isRenaming: boolean; + isDeleting: boolean; + isDisabled: boolean; + onApply: () => void; + onStartRename: () => void; + onCancelRename: () => void; + onRenameComplete: (newName: string) => Promise; + onStartDelete: () => void; + onCancelDelete: () => void; + onDeleteConfirm: () => Promise; + onSetDefault: () => void; + savedSearches: SavedSearch[]; + /** Portal root for the action menu - should be the outer dropdown container */ + menuPortalRoot?: HTMLElement | null; +} + +export function SavedSearchItem({ + search, + isRenaming, + isDeleting, + isDisabled, + onApply, + onStartRename, + onCancelRename, + onRenameComplete, + onStartDelete, + onCancelDelete, + onDeleteConfirm, + onSetDefault, + savedSearches, + menuPortalRoot, +}: SavedSearchItemProps) { + const styles = useStyles2(getStyles); + + // Rename mode - inline form matching the save form + // Stop propagation to prevent parent Dropdown from closing when interacting with form + if (isRenaming) { + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions +
e.stopPropagation()}> + +
+ ); + } + + // Delete confirm mode - inline with name visible + // Stop propagation to prevent parent Dropdown from closing when interacting with delete confirmation + if (isDeleting) { + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions +
e.stopPropagation()}> + + {/* Name remains visible */} + + {search.name} + + + {/* X icon - cancel delete */} + + + {/* Trash icon - confirm delete */} + + +
+ ); + } + + // Default display mode + return ( +
+ + {/* Name and default indicator */} + + {search.name} + {search.isDefault && ( + + )} + + + {/* Apply button (magnifying glass) */} + + + {/* Action menu - stop propagation to prevent parent Dropdown from closing */} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} +
e.stopPropagation()}> + +
+
+
+ ); +} + +// ============================================================================ +// Action Menu (three-dot menu) +// ============================================================================ + +interface ActionMenuProps { + isDefault: boolean; + isDisabled: boolean; + onSetDefault: () => void; + onRename: () => void; + onDelete: () => void; + /** Portal root for the menu - renders inside the outer dropdown to prevent useDismiss issues */ + portalRoot?: HTMLElement | null; +} + +function ActionMenu({ isDefault, isDisabled, onSetDefault, onRename, onDelete, portalRoot }: ActionMenuProps) { + const menu = ( + + + + + + + ); + + return ( + + + + ); +} + +// ============================================================================ +// Styles +// ============================================================================ + +function getStyles(theme: GrafanaTheme2) { + return { + item: css({ + padding: theme.spacing(0.5), + borderRadius: theme.shape.radius.default, + '&:hover': { + backgroundColor: theme.colors.action.hover, + }, + }), + defaultIcon: css({ + color: theme.colors.warning.main, + flexShrink: 0, + }), + deleteIcon: css({ + color: theme.colors.error.main, + }), + actionMenuWrapper: css({ + display: 'flex', + alignItems: 'center', + }), + }; +} diff --git a/public/app/features/alerting/unified/rule-list/filter/SavedSearches.README.md b/public/app/features/alerting/unified/rule-list/filter/SavedSearches.README.md new file mode 100644 index 00000000000..8935cecaaa1 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/filter/SavedSearches.README.md @@ -0,0 +1,404 @@ +# Saved Searches Feature + +The Saved Searches feature allows users to save, manage, and quickly apply search queries on the Alert Rules page. + +## Overview + +Users can: + +- **Save** the current search query with a custom name +- **Apply** a saved search to instantly filter rules +- **Rename** existing saved searches +- **Delete** saved searches they no longer need +- **Set a default** search that auto-applies when navigating to the page + +## Components + +### `` + +The main component that renders a button and dropdown for managing saved searches. + +```tsx +import { SavedSearches } from './SavedSearches'; + +; +``` + +#### Props + +| Prop | Type | Required | Description | +| -------------------- | ------------------------------------------------------------------- | -------- | ------------------------------------------- | +| `savedSearches` | `SavedSearch[]` | Yes | Array of saved search objects | +| `currentSearchQuery` | `string` | Yes | The current search query in the input field | +| `onSave` | `(name: string, query: string) => Promise` | Yes | Called when user saves a new search | +| `onRename` | `(id: string, newName: string) => Promise` | Yes | Called when user renames a search | +| `onDelete` | `(id: string) => Promise` | Yes | Called when user deletes a search | +| `onApply` | `(search: SavedSearch) => void` | Yes | Called when user applies a search | +| `onSetDefault` | `(id: string \| null) => Promise` | Yes | Called when user sets/clears default | + +#### Types + +```typescript +interface SavedSearch { + /** Unique identifier */ + id: string; + /** User-provided name */ + name: string; + /** The search query string */ + query: string; + /** Whether this is the default search */ + isDefault: boolean; + /** Unix timestamp of creation */ + createdAt: number; +} + +interface ValidationError { + /** The field with the error */ + field: string; + /** Error message to display */ + message: string; +} +``` + +### `useSavedSearches()` Hook + +A custom hook that manages saved searches with UserStorage persistence. + +```tsx +import { useSavedSearches, trackSavedSearchApplied } from './useSavedSearches'; + +const { savedSearches, isLoading, saveSearch, renameSearch, deleteSearch, setDefaultSearch, getAutoApplySearch } = + useSavedSearches(); + +// Track when a search is applied +const handleApply = (search: SavedSearch) => { + applySearchToFilter(search.query); + trackSavedSearchApplied(search); +}; +``` + +#### Return Value + +| Property | Type | Description | +| -------------------- | --------------------------------------------------- | ------------------------------------- | +| `savedSearches` | `SavedSearch[]` | Current list of saved searches | +| `isLoading` | `boolean` | Whether initial load is in progress | +| `saveSearch` | `(name, query) => Promise` | Save a new search | +| `renameSearch` | `(id, newName) => Promise` | Rename an existing search | +| `deleteSearch` | `(id) => Promise` | Delete a search | +| `setDefaultSearch` | `(id \| null) => Promise` | Set or clear the default search | +| `getAutoApplySearch` | `() => SavedSearch \| null` | Get the default search for auto-apply | + +## Integration + +### Basic Integration + +```tsx +import { SavedSearches, SavedSearch } from './SavedSearches'; +import { useSavedSearches, trackSavedSearchApplied } from './useSavedSearches'; + +function MyFilterComponent() { + const { filterState, updateFilters } = useMyFilter(); + + const { savedSearches, saveSearch, renameSearch, deleteSearch, setDefaultSearch, getAutoApplySearch } = + useSavedSearches(); + + // Handle applying a saved search + const handleApply = useCallback( + (search: SavedSearch) => { + // Update your filter state with the saved query + updateFilters(parseQuery(search.query)); + + // Track analytics + trackSavedSearchApplied(search); + }, + [updateFilters] + ); + + // Auto-apply default search on navigation + useEffect(() => { + const defaultSearch = getAutoApplySearch(); + if (defaultSearch) { + handleApply(defaultSearch); + } + }, [getAutoApplySearch, handleApply]); + + return ( + + ); +} +``` + +### Feature Toggle + +The feature is gated behind the `alertingSavedSearches` feature toggle: + +```tsx +import { shouldUseSavedSearches } from '../../featureToggles'; + +function MyComponent() { + const savedSearchesEnabled = shouldUseSavedSearches(); + + return ( + <> + {savedSearchesEnabled && } + + ); +} +``` + +To enable during development, set in Grafana config: + +```ini +[feature_toggles] +alertingSavedSearches = true +``` + +## Behavior + +### Dropdown States + +1. **List Mode** (default) + - Shows saved searches sorted: default first, then alphabetical + - Shows "Save current search" button when `currentSearchQuery` is non-empty + - Empty state when no saved searches exist + +2. **Save Mode** + - Name input with validation + - Save/Cancel buttons + - Triggered by clicking "Save current search" + +3. **Rename Mode** (per-item) + - Inline editing of search name + - Confirm with Enter, cancel with Escape + +4. **Delete Confirmation** (per-item) + - Inline confirmation prompt + - Delete/Cancel buttons + +### Validation Rules + +| Rule | Message | +| ------------------------------ | ---------------------------------------------- | +| Name required | "Name is required" | +| Max length 64 | "Name must be 64 characters or less" | +| Unique name (case-insensitive) | "A saved search with this name already exists" | + +### Auto-Apply Default Search + +The default search auto-applies when: + +1. User **navigates** to the Alert Rules page (not on refresh) +2. No search query is present in the URL +3. A default search is configured + +This is tracked via `sessionStorage` to distinguish navigation from refresh. + +### Persistence + +Saved searches are stored using `UserStorage`: + +- **Backend API**: `/apis/userstorage.grafana.app/v0alpha1/namespaces/{namespace}/user-storage` +- **Fallback**: `localStorage` (when user not signed in or API fails) +- **Storage key**: `alerting.savedSearches` + +## Analytics + +The feature tracks the following events via `reportInteraction`: + +| Event | Properties | When | +| ------------------------------------------- | -------------------------- | -------------------- | +| `grafana_alerting_saved_search_save` | `hasDefault`, `totalCount` | Search saved | +| `grafana_alerting_saved_search_apply` | `isDefault` | Search applied | +| `grafana_alerting_saved_search_delete` | - | Search deleted | +| `grafana_alerting_saved_search_rename` | - | Search renamed | +| `grafana_alerting_saved_search_set_default` | `action: 'set' \| 'clear'` | Default changed | +| `grafana_alerting_saved_search_auto_apply` | - | Default auto-applied | + +## Testing + +### Component Tests + +Location: `SavedSearches.test.tsx` + +```bash +yarn test SavedSearches.test.tsx +``` + +Test categories: + +- **Rendering**: Button, dropdown, list sorting, empty state +- **Save functionality**: Validation, errors, success flow +- **Apply functionality**: Click handling, dropdown close +- **Action menu**: Set default, rename, delete options +- **Delete confirmation**: Confirm/cancel flows +- **Keyboard navigation**: Escape key handling +- **Edge cases**: Empty queries, whitespace trimming + +### Hook Tests + +Location: `useSavedSearches.test.tsx` + +```bash +yarn test useSavedSearches.test.tsx +``` + +Test categories: + +- **Initial loading**: Loading state, storage load, empty storage +- **saveSearch**: New search, duplicate detection, analytics +- **renameSearch**: Rename flow, duplicate detection +- **deleteSearch**: Delete flow, analytics +- **setDefaultSearch**: Set/clear default, analytics +- **getAutoApplySearch**: Navigation detection, URL check +- **Error handling**: Storage errors, notifications + +### Mocking UserStorage API with MSW + +The hook and component tests use MSW to mock the UserStorage API endpoints. +The handlers are defined in `mocks/server/handlers/userStorage.ts` and included via `setupMswServer()`: + +```typescript +import { setupMswServer, setAlertingStorageItem, getAlertingStorageItem } from 'app/features/alerting/unified/mockApi'; + +// Set up MSW server with UserStorage handlers (call at module level) +setupMswServer(); + +// In tests, use helper functions to set up storage data: +it('should load saved searches', async () => { + setAlertingStorageItem('savedSearches', JSON.stringify(mockSavedSearches)); + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.savedSearches).toEqual(mockSavedSearches); +}); + +// Verify persisted data: +it('should save a new search', async () => { + // ... perform save action ... + + const storedData = getAlertingStorageItem('savedSearches'); + expect(storedData).toContain('"name":"New Search"'); +}); +``` + +**Important**: Tests must mock `config.namespace` and `config.bootData.user` before imports, +so that `UserStorage` constructs the correct API URLs: + +```typescript +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + config: { + ...jest.requireActual('@grafana/runtime').config, + namespace: 'default', + bootData: { + ...jest.requireActual('@grafana/runtime').config.bootData, + user: { uid: 'test-user-123', id: 123, isSignedIn: true }, + }, + }, +})); +``` + +### Verifying Notifications in UI + +Tests verify error notifications by rendering the `AppNotificationList` component: + +```typescript +import { AppNotificationList } from 'app/core/components/AppNotifications/AppNotificationList'; +import { getWrapper, screen } from 'test/test-utils'; + +function createWrapper() { + const Wrapper = getWrapper({ renderWithRouter: true }); + return function WrapperWithNotifications({ children }) { + return ( + + + {children} + + ); + }; +} + +// In tests (e.g., malformed JSON triggers error notification): +it('should handle malformed JSON gracefully', async () => { + setAlertingStorageItem('savedSearches', 'not valid json'); + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(await screen.findByText(/failed to load saved searches/i)).toBeInTheDocument(); +}); +``` + +## Accessibility + +- Dropdown has `role="dialog"` for screen readers +- Action menu uses `@grafana/ui` `Dropdown` and `Menu` components +- Keyboard support: + - `Escape`: Close dropdown or cancel current operation + - `Tab`: Navigate through interactive elements + - `Enter`: Confirm inputs + +## File Structure + +``` +public/app/features/alerting/unified/rule-list/filter/ +├── SavedSearches.tsx # Main component +├── SavedSearches.test.tsx # Component tests +├── SavedSearches.README.md # This documentation +├── useSavedSearches.ts # Custom hook with persistence +└── useSavedSearches.test.tsx # Hook tests +``` + +## Dependencies + +- `@grafana/ui`: Button, Dropdown, Menu, Icon, Input, Stack, Box, Spinner, PopupCard +- `@grafana/i18n`: Trans, t (internationalization) +- `@grafana/runtime`: reportInteraction +- `@grafana/runtime/internal`: UserStorage +- `@emotion/css`: Styling via useStyles2 + +## E2E Tests + +Location: `e2e-playwright/alerting-suite/saved-searches.spec.ts` + +```bash +yarn e2e:playwright --grep "saved-searches" +``` + +Test scenarios: + +- Display Saved searches button +- Open/close dropdown +- Empty state +- Save current search (enabled/disabled) +- Create new saved search +- Validation errors (duplicate name) +- Apply saved search +- Rename saved search +- Delete saved search +- Set as default +- Keyboard navigation (Escape to close/cancel) diff --git a/public/app/features/alerting/unified/rule-list/filter/SavedSearches.test.tsx b/public/app/features/alerting/unified/rule-list/filter/SavedSearches.test.tsx new file mode 100644 index 00000000000..7e3604c5eec --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/filter/SavedSearches.test.tsx @@ -0,0 +1,348 @@ +import { render, screen, waitFor } from 'test/test-utils'; +import { byPlaceholderText, byRole, byText } from 'testing-library-selector'; + +import { SavedSearches } from './SavedSearches'; +import { SavedSearch } from './savedSearchesSchema'; + +/** + * UI selectors for SavedSearches component tests. + * Using testing-library-selector for reusable, consistent selectors. + */ +const ui = { + // Main trigger button + savedSearchesButton: byRole('button', { name: /saved searches/i }), + // Dropdown dialog + dropdown: byRole('dialog'), + // Save functionality + saveButton: byRole('button', { name: /save current search/i }), + saveConfirmButton: byRole('button', { name: /save$/i }), + saveInput: byPlaceholderText(/enter a name/i), + // Action buttons + cancelButton: byRole('button', { name: /cancel/i }), + applyButtons: byRole('button', { name: /apply this search/i }), + actionMenuButtons: byRole('button', { name: /actions/i }), + deleteButton: byRole('button', { name: /delete/i }), + // Menu items (using byRole for proper accessibility testing) + setAsDefaultMenuItem: byRole('menuitem', { name: /set as default/i }), + removeDefaultMenuItem: byRole('menuitem', { name: /remove default/i }), + renameMenuItem: byRole('menuitem', { name: /rename/i }), + deleteMenuItem: byRole('menuitem', { name: /^delete$/i }), + // Messages + emptyStateMessage: byText(/no saved searches/i), + nameRequiredError: byText(/name is required/i), + duplicateNameError: byText(/a saved search with this name already exists/i), +}; + +// Mock data is ordered as it will appear after sorting by useSavedSearches: +// default search first, then alphabetically by name +const mockSavedSearches: SavedSearch[] = [ + { + id: '2', + name: 'Default Search', + query: 'label:team=A', + isDefault: true, + createdAt: Date.now() - 2000, + }, + { + id: '3', + name: 'Critical Alerts', + query: 'label:severity=critical state:firing', + isDefault: false, + createdAt: Date.now() - 3000, + }, + { + id: '1', + name: 'My Firing Rules', + query: 'state:firing', + isDefault: false, + createdAt: Date.now() - 1000, + }, +]; + +const defaultProps = { + savedSearches: mockSavedSearches, + currentSearchQuery: '', + onSave: jest.fn(), + onRename: jest.fn(), + onDelete: jest.fn(), + onApply: jest.fn(), + onSetDefault: jest.fn(), +}; + +describe('SavedSearches', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Displaying saved searches', () => { + it('shows empty state when no saved searches exist', async () => { + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + + expect(ui.emptyStateMessage.get()).toBeInTheDocument(); + }); + + it('displays saved searches with default search marked with star icon', async () => { + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + + // Verify searches are displayed + const applyButtons = await ui.applyButtons.findAll(); + expect(applyButtons).toHaveLength(3); + + // Verify the default search has a star icon + expect(screen.getByText('Default Search')).toBeInTheDocument(); + expect(screen.getByTitle('Default search')).toBeInTheDocument(); + }); + }); + + describe('Saving a search', () => { + it('saves current search with the provided name', async () => { + defaultProps.onSave.mockResolvedValue(undefined); + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + await user.click(await ui.saveButton.find()); + await user.type(await ui.saveInput.find(), 'My New Search'); + await user.click(ui.saveConfirmButton.get()); + + expect(defaultProps.onSave).toHaveBeenCalledWith('My New Search', 'state:pending'); + }); + + it('disables save button when currentSearchQuery is empty', async () => { + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + + // Use findByText + closest because findByRole fails to find disabled Grafana Button + // (due to aria-disabled="false" + disabled="" attribute mismatch in Grafana UI) + const saveButtonText = await screen.findByText(/save current search/i); + // eslint-disable-next-line testing-library/no-node-access + const saveButton = saveButtonText.closest('button'); + expect(saveButton).toBeDisabled(); + }); + + it('shows validation error when name is empty', async () => { + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + await user.click(await ui.saveButton.find()); + await user.click(await ui.saveConfirmButton.find()); + + expect(await ui.nameRequiredError.find()).toBeInTheDocument(); + }); + + it('shows validation error for duplicate name', async () => { + defaultProps.onSave.mockResolvedValue({ + field: 'name', + message: 'A saved search with this name already exists', + }); + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + await user.click(await ui.saveButton.find()); + await user.type(await ui.saveInput.find(), 'My Firing Rules'); + await user.click(ui.saveConfirmButton.get()); + + expect(await ui.duplicateNameError.find()).toBeInTheDocument(); + }); + + it('trims whitespace from search name before saving', async () => { + defaultProps.onSave.mockResolvedValue(undefined); + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + await user.click(await ui.saveButton.find()); + await user.type(await ui.saveInput.find(), ' My Search '); + await user.click(ui.saveConfirmButton.get()); + + expect(defaultProps.onSave).toHaveBeenCalledWith('My Search', 'state:pending'); + }); + + it('cancels save when cancel button is clicked', async () => { + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + await user.click(await ui.saveButton.find()); + await user.click(await ui.cancelButton.find()); + + expect(defaultProps.onSave).not.toHaveBeenCalled(); + expect(ui.saveInput.query()).not.toBeInTheDocument(); + }); + }); + + describe('Applying a search', () => { + it('applies the selected search and closes dropdown', async () => { + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + const applyButtons = await ui.applyButtons.findAll(); + // Click the apply button for "My Firing Rules" (third in list: Default, Critical, My Firing) + await user.click(applyButtons[2]); + + expect(defaultProps.onApply).toHaveBeenCalledWith( + expect.objectContaining({ + id: '1', + name: 'My Firing Rules', + query: 'state:firing', + }) + ); + + await waitFor(() => { + expect(ui.dropdown.query()).not.toBeInTheDocument(); + }); + }); + }); + + describe('Setting default search', () => { + it('sets a search as default', async () => { + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + // Use a non-default search's menu (second item: "Critical Alerts") + const menuButtons = await ui.actionMenuButtons.findAll(); + await user.click(menuButtons[1]); + await user.click(await ui.setAsDefaultMenuItem.find()); + + expect(defaultProps.onSetDefault).toHaveBeenCalledWith('3'); + }); + + it('removes default from a search', async () => { + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + // Default Search is first item (index 0) + const menuButtons = await ui.actionMenuButtons.findAll(); + await user.click(menuButtons[0]); + await user.click(await ui.removeDefaultMenuItem.find()); + + expect(defaultProps.onSetDefault).toHaveBeenCalledWith(null); + }); + }); + + describe('Renaming a search', () => { + it('renames a search successfully', async () => { + defaultProps.onRename.mockResolvedValue(undefined); + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + const menuButtons = await ui.actionMenuButtons.findAll(); + await user.click(menuButtons[0]); + await user.click(await ui.renameMenuItem.find()); + + const input = await screen.findByDisplayValue('Default Search'); + await user.clear(input); + await user.type(input, 'Renamed Search'); + await user.keyboard('{Enter}'); + + expect(defaultProps.onRename).toHaveBeenCalledWith('2', 'Renamed Search'); + }); + + it('shows validation error for duplicate name when renaming', async () => { + defaultProps.onRename.mockResolvedValue({ + field: 'name', + message: 'A saved search with this name already exists', + }); + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + const menuButtons = await ui.actionMenuButtons.findAll(); + await user.click(menuButtons[0]); + await user.click(await ui.renameMenuItem.find()); + + const input = await screen.findByDisplayValue('Default Search'); + await user.clear(input); + await user.type(input, 'My Firing Rules'); + await user.keyboard('{Enter}'); + + await waitFor(() => { + expect(ui.duplicateNameError.get()).toBeInTheDocument(); + }); + }); + }); + + describe('Deleting a search', () => { + it('deletes a search after confirmation', async () => { + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + const menuButtons = await ui.actionMenuButtons.findAll(); + // Delete the first item (Default Search, id: '2') + await user.click(menuButtons[0]); + await user.click(await ui.deleteMenuItem.find()); + + // Confirm delete + const deleteButtons = await ui.deleteButton.findAll(); + await user.click(deleteButtons[deleteButtons.length - 1]); + + expect(defaultProps.onDelete).toHaveBeenCalledWith('2'); + }); + + it('cancels delete when cancel is clicked', async () => { + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + const menuButtons = await ui.actionMenuButtons.findAll(); + await user.click(menuButtons[0]); + await user.click(await ui.deleteMenuItem.find()); + + await user.click(await ui.cancelButton.find()); + + expect(defaultProps.onDelete).not.toHaveBeenCalled(); + expect(screen.getByText('Default Search')).toBeInTheDocument(); + }); + }); + + describe('Keyboard navigation', () => { + it('closes dropdown when Escape is pressed', async () => { + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + expect(await ui.dropdown.find()).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + + await waitFor(() => { + expect(ui.dropdown.query()).not.toBeInTheDocument(); + }); + }); + + it('cancels save mode when Escape is pressed without closing dropdown', async () => { + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + await user.click(await ui.saveButton.find()); + expect(await ui.saveInput.find()).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + + await waitFor(() => { + expect(ui.saveInput.query()).not.toBeInTheDocument(); + }); + // Dropdown should still be open + expect(ui.dropdown.get()).toBeInTheDocument(); + }); + }); + + describe('Edge cases', () => { + it('handles empty search query display gracefully', async () => { + const searchesWithEmptyQuery: SavedSearch[] = [ + { + id: '1', + name: 'Empty Search', + query: '', + isDefault: false, + createdAt: Date.now(), + }, + ]; + + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + + expect(await screen.findByText('Empty Search')).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/alerting/unified/rule-list/filter/SavedSearches.tsx b/public/app/features/alerting/unified/rule-list/filter/SavedSearches.tsx new file mode 100644 index 00000000000..0d4391642f0 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/filter/SavedSearches.tsx @@ -0,0 +1,556 @@ +/** + * SavedSearches Component + * + * Allows users to save, manage, and quickly apply search queries on the Alert Rules page. + * + * ## Features + * - Save current search query with a custom name + * - Mark one search as "default" (auto-applied on navigation) + * - Rename, delete, and apply saved searches + * - Alphabetical sorting with default search pinned first + * + * ## Props + * @param savedSearches - Array of saved search objects + * @param currentSearchQuery - The current search query string from the filter state + * @param onSave - Callback to save a new search. Throws ValidationError on failure. + * @param onRename - Callback to rename an existing search. Throws ValidationError on failure. + * @param onDelete - Callback to delete a search + * @param onApply - Callback when a saved search is applied + * @param onSetDefault - Callback to set/unset default search (pass null to unset) + * @param disabled - Disables all interactions + * @param className - Additional CSS class name + * + * ## Internal States + * - Dropdown open/closed + * - Save mode (inputting new search name) + * - Rename mode (editing existing search name, by item ID) + * - Delete confirm mode (confirming deletion, by item ID) + * + * ## Accessibility + * - Uses role="dialog" for the dropdown panel + * - Basic keyboard navigation (Escape to close, Tab to navigate) + * - Focus moves to "Save current search" button when dropdown opens + * + * @example + * ```tsx + * + * ``` + */ + +import { css } from '@emotion/css'; +import { useCallback, useEffect, useReducer, useRef } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Trans, t } from '@grafana/i18n'; +import { Box, Button, Spinner, Stack, Text, useStyles2 } from '@grafana/ui'; + +import { PopupCard } from '../../components/HoverCard'; + +import { InlineSaveInput } from './InlineSaveInput'; +import { SavedSearchItem } from './SavedSearchItem'; +import { SavedSearch } from './savedSearchesSchema'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface SavedSearchesProps { + /** Array of saved search objects */ + savedSearches: SavedSearch[]; + /** The current search query string from the filter state */ + currentSearchQuery: string; + /** Callback to save a new search. Throws ValidationError on failure. */ + onSave: (name: string, query: string) => Promise; + /** Callback to rename an existing search. Throws ValidationError on failure. */ + onRename: (id: string, newName: string) => Promise; + /** Callback to delete a search */ + onDelete: (id: string) => Promise; + /** Callback when a saved search is applied */ + onApply: (search: SavedSearch) => void; + /** Callback to set/unset default search. Pass null to remove default. */ + onSetDefault: (id: string | null) => Promise; + /** Whether saved searches are still loading from storage */ + isLoading?: boolean; + /** Additional CSS class name */ + className?: string; +} + +// ============================================================================ +// State Management (Reducer) +// ============================================================================ + +// Active action type - represents the current action in progress +type ActiveAction = 'idle' | { type: 'saving' } | { type: 'renaming'; id: string } | { type: 'deleting'; id: string }; + +// Component state +interface DropdownState { + isOpen: boolean; + activeAction: ActiveAction; +} + +// Action types for the reducer +type DropdownAction = + | { type: 'OPEN' } + | { type: 'CLOSE' } + | { type: 'SET_VISIBLE'; visible: boolean } + | { type: 'START_SAVE' } + | { type: 'START_RENAME'; id: string } + | { type: 'START_DELETE'; id: string } + | { type: 'CANCEL_ACTION' } + | { type: 'COMPLETE_ACTION' } + | { type: 'APPLY_AND_CLOSE' }; + +const initialState: DropdownState = { + isOpen: false, + activeAction: 'idle', +}; + +/** + * Reducer for managing dropdown state and active actions. + * Centralizes all state transitions for easier reasoning and testing. + */ +function dropdownReducer(state: DropdownState, action: DropdownAction): DropdownState { + switch (action.type) { + case 'OPEN': + return { ...state, isOpen: true }; + + case 'CLOSE': + // Reset action when closing + return { isOpen: false, activeAction: 'idle' }; + + case 'SET_VISIBLE': + // When visibility changes, reset action if closing + return action.visible ? { ...state, isOpen: true } : { isOpen: false, activeAction: 'idle' }; + + case 'START_SAVE': + // Only start save if no action is active + return state.activeAction === 'idle' ? { ...state, activeAction: { type: 'saving' } } : state; + + case 'START_RENAME': + // Only start rename if no action is active + return state.activeAction === 'idle' ? { ...state, activeAction: { type: 'renaming', id: action.id } } : state; + + case 'START_DELETE': + // Only start delete if no action is active + return state.activeAction === 'idle' ? { ...state, activeAction: { type: 'deleting', id: action.id } } : state; + + case 'CANCEL_ACTION': + case 'COMPLETE_ACTION': + // Return to idle state + return { ...state, activeAction: 'idle' }; + + case 'APPLY_AND_CLOSE': + // Only apply if no action is active, then close + return state.activeAction === 'idle' ? { isOpen: false, activeAction: 'idle' } : state; + + default: + return state; + } +} + +// ============================================================================ +// Main Component +// ============================================================================ + +export function SavedSearches({ + savedSearches, + currentSearchQuery, + onSave, + onRename, + onDelete, + onApply, + onSetDefault, + isLoading = false, + className, +}: SavedSearchesProps) { + const styles = useStyles2(getStyles); + + // Centralized state management via reducer + const [state, dispatch] = useReducer(dropdownReducer, initialState); + const { isOpen, activeAction } = state; + + // Refs + const saveButtonRef = useRef(null); + const dialogRef = useRef(null); + + // Focus dialog when dropdown opens to enable keyboard navigation + useEffect(() => { + if (isOpen && activeAction === 'idle') { + // Small delay to ensure the dropdown is rendered + const timer = setTimeout(() => { + // Focus the dialog to capture keyboard events (like Escape) + // We focus the dialog instead of the save button because the button may be disabled + dialogRef.current?.focus(); + }, 50); + return () => clearTimeout(timer); + } + return undefined; + }, [isOpen, activeAction]); + + // Handle click outside to close dropdown - excludes portal elements (like action menu) + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (isOpen && dialogRef.current && event.target instanceof Node && !dialogRef.current.contains(event.target)) { + // Check if click is on a portal element (action menu dropdown) + if (event.target instanceof Element) { + const isPortalClick = + event.target.closest('[data-popper-placement]') || event.target.closest('[role="menu"]'); + + if (!isPortalClick) { + dispatch({ type: 'CLOSE' }); + } + } else { + dispatch({ type: 'CLOSE' }); + } + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + // Handle Escape key: cancel active action first, or close dropdown if no action is active + useEffect(() => { + const handleEscapeKey = (event: KeyboardEvent) => { + if (event.key === 'Escape' && isOpen) { + if (activeAction !== 'idle') { + dispatch({ type: 'CANCEL_ACTION' }); + } else { + dispatch({ type: 'CLOSE' }); + } + } + }; + + document.addEventListener('keydown', handleEscapeKey); + return () => document.removeEventListener('keydown', handleEscapeKey); + }, [isOpen, activeAction]); + + // Handlers + const handleToggle = useCallback(() => { + dispatch({ type: isOpen ? 'CLOSE' : 'OPEN' }); + }, [isOpen]); + + const handleClose = useCallback(() => { + dispatch({ type: 'CLOSE' }); + }, []); + + const handleStartSave = useCallback(() => { + dispatch({ type: 'START_SAVE' }); + }, []); + + const handleCancelSave = useCallback(() => { + dispatch({ type: 'CANCEL_ACTION' }); + }, []); + + const handleSaveComplete = useCallback( + async (name: string): Promise => { + await onSave(name, currentSearchQuery); + dispatch({ type: 'COMPLETE_ACTION' }); + }, + [onSave, currentSearchQuery] + ); + + const handleStartRename = useCallback((id: string) => { + dispatch({ type: 'START_RENAME', id }); + }, []); + + const handleCancelRename = useCallback(() => { + dispatch({ type: 'CANCEL_ACTION' }); + }, []); + + const handleRenameComplete = useCallback( + async (id: string, newName: string): Promise => { + await onRename(id, newName); + dispatch({ type: 'COMPLETE_ACTION' }); + }, + [onRename] + ); + + const handleStartDelete = useCallback((id: string) => { + dispatch({ type: 'START_DELETE', id }); + }, []); + + const handleCancelDelete = useCallback(() => { + dispatch({ type: 'CANCEL_ACTION' }); + }, []); + + const handleDeleteConfirm = useCallback( + async (id: string) => { + await onDelete(id); + dispatch({ type: 'COMPLETE_ACTION' }); + }, + [onDelete] + ); + + const handleApply = useCallback( + (search: SavedSearch) => { + // Only allow apply when no action is active (handled by reducer) + if (activeAction !== 'idle') { + return; + } + onApply(search); + dispatch({ type: 'APPLY_AND_CLOSE' }); + }, + [onApply, activeAction] + ); + + const handleSetDefault = useCallback( + async (id: string | null) => { + // Only allow set default when no action is active + if (activeAction !== 'idle') { + return; + } + await onSetDefault(id); + }, + [onSetDefault, activeAction] + ); + + const buttonLabel = t('alerting.saved-searches.button-label', 'Saved searches'); + const hasSearches = savedSearches.length > 0; + const canSave = currentSearchQuery.trim().length > 0; + + const content = ( +
+ +
+ ); + + return ( + + + + ); +} + +// ============================================================================ +// List Mode (shows saved searches or empty state) +// ============================================================================ + +interface ListModeProps { + searches: SavedSearch[]; + hasSearches: boolean; + canSave: boolean; + activeAction: ActiveAction; + saveButtonRef: React.RefObject; + isLoading: boolean; + onStartSave: () => void; + /** Callback to complete save. Throws ValidationError on validation failure. */ + onSaveComplete: (name: string) => Promise; + onCancelSave: () => void; + onApply: (search: SavedSearch) => void; + onStartRename: (id: string) => void; + onCancelRename: () => void; + /** Callback to complete rename. Throws ValidationError on validation failure. */ + onRenameComplete: (id: string, newName: string) => Promise; + onStartDelete: (id: string) => void; + onCancelDelete: () => void; + onDeleteConfirm: (id: string) => Promise; + onSetDefault: (id: string | null) => Promise; + savedSearches: SavedSearch[]; + /** Portal root for action menus - renders inside the dropdown to prevent useDismiss issues */ + menuPortalRoot: HTMLElement | null; +} + +function ListMode({ + searches, + hasSearches, + canSave, + activeAction, + saveButtonRef, + isLoading, + onStartSave, + onSaveComplete, + onCancelSave, + onApply, + onStartRename, + onCancelRename, + onRenameComplete, + onStartDelete, + onCancelDelete, + onDeleteConfirm, + onSetDefault, + savedSearches, + menuPortalRoot, +}: ListModeProps) { + const styles = useStyles2(getStyles); + + // Derived states from activeAction + const isSaveMode = typeof activeAction === 'object' && activeAction.type === 'saving'; + const isActionActive = activeAction !== 'idle'; + + // Show loading state + if (isLoading) { + return ( + + + + ); + } + + return ( + + {/* Save current search - button or inline input */} + {/* Stop propagation to prevent Dropdown from closing when interacting with save form */} + {isSaveMode ? ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
e.stopPropagation()}> + +
+ ) : ( + + + + )} + + {/* Empty state or list */} + {!hasSearches ? ( + + ) : ( + +
+ {searches.map((search) => { + const isRenaming = + typeof activeAction === 'object' && activeAction.type === 'renaming' && activeAction.id === search.id; + const isDeleting = + typeof activeAction === 'object' && activeAction.type === 'deleting' && activeAction.id === search.id; + // Item is disabled if any action is active and this item is not the one being acted upon + const isItemDisabled = isActionActive && !isRenaming && !isDeleting; + + return ( + onApply(search)} + onStartRename={() => onStartRename(search.id)} + onCancelRename={onCancelRename} + onRenameComplete={(newName) => onRenameComplete(search.id, newName)} + onStartDelete={() => onStartDelete(search.id)} + onCancelDelete={onCancelDelete} + onDeleteConfirm={() => onDeleteConfirm(search.id)} + onSetDefault={() => onSetDefault(search.isDefault ? null : search.id)} + savedSearches={savedSearches} + menuPortalRoot={menuPortalRoot} + /> + ); + })} +
+
+ )} +
+ ); +} + +// ============================================================================ +// Empty State +// ============================================================================ + +function EmptyState() { + return ( + + + No saved searches yet + + + ); +} + +// ============================================================================ +// Styles +// ============================================================================ + +function getStyles(theme: GrafanaTheme2) { + return { + dropdown: css({ + width: '320px', + padding: theme.spacing(0.5), + }), + list: css({ + maxHeight: '300px', + overflowY: 'auto', + }), + item: css({ + padding: theme.spacing(0.5), + borderRadius: theme.shape.radius.default, + '&:hover': { + backgroundColor: theme.colors.action.hover, + }, + }), + }; +} diff --git a/public/app/features/alerting/unified/rule-list/filter/savedSearchesSchema.ts b/public/app/features/alerting/unified/rule-list/filter/savedSearchesSchema.ts new file mode 100644 index 00000000000..807ce421957 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/filter/savedSearchesSchema.ts @@ -0,0 +1,87 @@ +/** + * Shared types and utilities for SavedSearches feature. + * + * This module is extracted to avoid circular dependencies between: + * - SavedSearches.tsx (main component) + * - InlineSaveInput.tsx, InlineRenameInput.tsx, SavedSearchItem.tsx (sub-components) + * - useSavedSearches.ts (hook) + */ + +import z from 'zod'; + +import { t } from '@grafana/i18n'; + +// ============================================================================ +// Schemas +// ============================================================================ + +/** + * Zod schema for validating a saved search object. + * Used to validate data loaded from storage. + */ +export const savedSearchSchema = z.object({ + id: z.string(), + name: z.string(), + isDefault: z.boolean(), + query: z.string(), + createdAt: z.number().optional(), +}); + +/** + * Zod schema for validating an array of saved searches. + */ +export const savedSearchesArraySchema = z.array(savedSearchSchema); + +// ============================================================================ +// Types +// ============================================================================ + +export type SavedSearch = z.infer; + +export interface ValidationError { + field: 'name'; + message: string; +} + +/** + * Type guard to check if an error is a ValidationError. + */ +export function isValidationError(error: unknown): error is ValidationError { + return Boolean( + error && + typeof error === 'object' && + 'field' in error && + 'message' in error && + error.field === 'name' && + typeof error.message === 'string' + ); +} + +// ============================================================================ +// Validation Utilities +// ============================================================================ + +/** + * Validates a saved search name. + * @param name - The name to validate + * @param savedSearches - Existing saved searches for uniqueness check + * @param excludeId - Optional ID to exclude from uniqueness check (for rename) + * @returns Error message string or null if valid + */ +export function validateSearchName(name: string, savedSearches: SavedSearch[], excludeId?: string): string | null { + const trimmed = name.trim(); + + if (!trimmed) { + return t('alerting.saved-searches.error-name-required', 'Name is required'); + } + + const isDuplicate = savedSearches.some( + (s) => (excludeId ? s.id !== excludeId : true) && s.name.toLowerCase() === trimmed.toLowerCase() + ); + + if (isDuplicate) { + return t('alerting.saved-searches.error-name-duplicate', 'A saved search with this name already exists'); + } + + return null; +} diff --git a/public/app/features/alerting/unified/rule-list/filter/useApplyDefaultSearch.ts b/public/app/features/alerting/unified/rule-list/filter/useApplyDefaultSearch.ts new file mode 100644 index 00000000000..380702fed16 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/filter/useApplyDefaultSearch.ts @@ -0,0 +1,86 @@ +import { useEffect } from 'react'; + +import { shouldUseSavedSearches } from '../../featureToggles'; +import { useAsync } from '../../hooks/useAsync'; +import { useRulesFilter } from '../../hooks/useFilteredRules'; +import { getSearchFilterFromQuery } from '../../search/rulesSearchParser'; + +import { loadDefaultSavedSearch, trackSavedSearchAutoApply } from './useSavedSearches'; + +/** + * Session storage key to track if user has visited this page in current session. + * Used to determine if we should auto-apply the default saved search. + */ +const SESSION_VISITED_KEY = 'grafana.alerting.ruleList.visited'; + +/** + * Hook that automatically applies the default saved search on first visit to the page. + * + * This hook: + * - Checks if saved searches feature is enabled + * - Detects if this is the first visit in the current session + * - Loads and applies the default saved search if one exists + * - Cleans up session storage on unmount + * + * @returns Object with isApplying boolean indicating if the default search is being loaded/applied + */ +export function useApplyDefaultSearch(): { isApplying: boolean } { + const savedSearchesEnabled = shouldUseSavedSearches(); + const { updateFilters, hasActiveFilters } = useRulesFilter(); + + // Use the internal useAsync hook which doesn't auto-execute + const [{ execute }, state] = useAsync(async () => { + const defaultSearch = await loadDefaultSavedSearch(); + if (defaultSearch) { + updateFilters(getSearchFilterFromQuery(defaultSearch.query)); + trackSavedSearchAutoApply(); + } + }); + + // Clear session storage on unmount + useEffect(() => { + return () => { + clearSessionVisitedFlag(); + }; + }, []); + + const isFirstVisit = isFirstVisitInSession(); + const shouldLoadDefault = savedSearchesEnabled && !hasActiveFilters && isFirstVisit; + + // Mark as visited on first visit, regardless of whether we load defaults + if (isFirstVisit && state.status === 'not-executed') { + markAsVisited(); + + // Execute only if we should load default + if (shouldLoadDefault) { + execute(); + } + } + + return { isApplying: state.status === 'loading' }; +} + +/** + * Check if this is a fresh navigation to the page (not a refresh or in-page URL change). + * Uses session storage which persists across refreshes but clears when tab is closed. + * + * @returns true if this is the first visit to the page in this session + */ +function isFirstVisitInSession(): boolean { + return !sessionStorage.getItem(SESSION_VISITED_KEY); +} + +/** + * Mark the page as visited in the current session. + */ +function markAsVisited(): void { + sessionStorage.setItem(SESSION_VISITED_KEY, 'true'); +} + +/** + * Clear the session visited flag. Call this when component unmounts + * so the next navigation to this page is detected as a fresh visit. + */ +function clearSessionVisitedFlag(): void { + sessionStorage.removeItem(SESSION_VISITED_KEY); +} diff --git a/public/app/features/alerting/unified/rule-list/filter/useSavedSearches.test.tsx b/public/app/features/alerting/unified/rule-list/filter/useSavedSearches.test.tsx new file mode 100644 index 00000000000..4ea83f1e38a --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/filter/useSavedSearches.test.tsx @@ -0,0 +1,400 @@ +import { PropsWithChildren } from 'react'; +import { act, getWrapper, renderHook, screen, waitFor } from 'test/test-utils'; + +import * as runtime from '@grafana/runtime'; +import { AppNotificationList } from 'app/core/components/AppNotifications/AppNotificationList'; +import { setupMswServer } from 'app/features/alerting/unified/mockApi'; + +// Create mock UserStorage instance that can be configured per test +const mockUserStorage = { + getItem: jest.fn(), + setItem: jest.fn(), +}; + +// Mock UserStorage class from @grafana/runtime/internal +// This prevents the module-level instance from caching state across tests +jest.mock('@grafana/runtime/internal', () => ({ + ...jest.requireActual('@grafana/runtime/internal'), + UserStorage: jest.fn().mockImplementation(() => mockUserStorage), +})); + +// Mock config BEFORE any imports that use it (jest.mock is hoisted) +// This ensures config.namespace and config.bootData.user are set when UserStorage module loads +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + reportInteraction: jest.fn(), + config: { + ...jest.requireActual('@grafana/runtime').config, + namespace: 'default', + bootData: { + ...jest.requireActual('@grafana/runtime').config.bootData, + navTree: [], + user: { + uid: 'test-user-123', + id: 123, + isSignedIn: true, + }, + }, + }, +})); + +import { trackSavedSearchApplied, useSavedSearches } from './useSavedSearches'; + +// Set up MSW server for other handlers (not UserStorage - that's mocked directly) +setupMswServer(); + +// Mock data is ordered as it will appear after sorting by useSavedSearches: +// default search first, then alphabetically by name +const mockSavedSearches = [ + { + id: '2', + name: 'Default Search', + query: 'label:team=A', + isDefault: true, + createdAt: Date.now() - 2000, + }, + { + id: '1', + name: 'Test Search 1', + query: 'state:firing', + isDefault: false, + createdAt: Date.now() - 1000, + }, +]; + +// Wrapper that includes AppNotificationList to verify UI notifications +function createWrapper() { + const Wrapper = getWrapper({ renderWithRouter: true }); + return function WrapperWithNotifications({ children }: PropsWithChildren) { + return ( + + + {children} + + ); + }; +} + +describe('useSavedSearches', () => { + beforeEach(() => { + jest.clearAllMocks(); + sessionStorage.clear(); + localStorage.clear(); + // Reset mock UserStorage to default behavior (empty storage) + mockUserStorage.getItem.mockResolvedValue(null); + mockUserStorage.setItem.mockResolvedValue(undefined); + }); + + describe('Initial loading', () => { + it('should load saved searches from UserStorage', async () => { + mockUserStorage.getItem.mockResolvedValue(JSON.stringify(mockSavedSearches)); + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.savedSearches).toEqual(mockSavedSearches); + }); + + it('should handle empty storage gracefully', async () => { + // Storage is empty by default after resetUserStorage() in afterEach + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.savedSearches).toEqual([]); + }); + + it('should handle 404 (no stored data) gracefully', async () => { + // When no data exists, UserStorage returns 404, which is handled as "not found" + // The hook should return an empty array without error + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // UserStorage handles 404 gracefully - returns empty storage + expect(result.current.savedSearches).toEqual([]); + }); + + it('should filter out invalid saved search entries', async () => { + jest.spyOn(console, 'warn').mockImplementation(); + const mixedData = [ + mockSavedSearches[0], // Valid + { id: '3', name: 'Invalid', query: 123, isDefault: false }, // Invalid query type + { id: '4', name: null, query: 'valid', isDefault: false }, // Invalid name type + mockSavedSearches[1], // Valid + ]; + mockUserStorage.getItem.mockResolvedValue(JSON.stringify(mixedData)); + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Invalid entries are filtered out, valid ones are preserved + expect(result.current.savedSearches).toHaveLength(2); + expect(result.current.savedSearches[0].name).toBe('Default Search'); + expect(result.current.savedSearches[1].name).toBe('Test Search 1'); + }); + + it('should handle malformed JSON gracefully', async () => { + jest.spyOn(console, 'error').mockImplementation(); + mockUserStorage.getItem.mockResolvedValue('not valid json'); + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Verify error notification appears in the UI + expect(await screen.findByText(/failed to load saved searches/i)).toBeInTheDocument(); + expect(result.current.savedSearches).toEqual([]); + }); + }); + + describe('saveSearch', () => { + it('should save a new search', async () => { + // Start with empty storage (default mock behavior) + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.saveSearch('New Search', 'state:pending'); + }); + + expect(result.current.savedSearches).toHaveLength(1); + expect(result.current.savedSearches[0].name).toBe('New Search'); + expect(result.current.savedSearches[0].query).toBe('state:pending'); + + // Verify data was persisted via UserStorage.setItem + expect(mockUserStorage.setItem).toHaveBeenCalledWith( + 'savedSearches', + expect.stringContaining('"name":"New Search"') + ); + }); + + it('should throw validation error for duplicate name (case-insensitive)', async () => { + mockUserStorage.getItem.mockResolvedValue(JSON.stringify(mockSavedSearches)); + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await expect(result.current.saveSearch('TEST SEARCH 1', 'state:pending')).rejects.toEqual({ + field: 'name', + message: expect.stringContaining('already exists'), + }); + }); + }); + + it('should track analytics on save', async () => { + // Start with empty storage + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.saveSearch('New Search', 'state:pending'); + }); + + expect(runtime.reportInteraction).toHaveBeenCalledWith( + 'grafana_alerting_saved_search_save', + expect.objectContaining({ + hasDefault: false, + totalCount: 1, + }) + ); + }); + }); + + describe('renameSearch', () => { + it('should rename an existing search', async () => { + mockUserStorage.getItem.mockResolvedValue(JSON.stringify(mockSavedSearches)); + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.renameSearch('1', 'Renamed Search'); + }); + + expect(result.current.savedSearches.find((s) => s.id === '1')?.name).toBe('Renamed Search'); + + // Verify data was persisted via UserStorage.setItem + expect(mockUserStorage.setItem).toHaveBeenCalledWith( + 'savedSearches', + expect.stringContaining('"name":"Renamed Search"') + ); + }); + + it('should throw validation error for duplicate name on rename', async () => { + mockUserStorage.getItem.mockResolvedValue(JSON.stringify(mockSavedSearches)); + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await expect(result.current.renameSearch('1', 'Default Search')).rejects.toEqual({ + field: 'name', + message: expect.stringContaining('already exists'), + }); + }); + }); + }); + + describe('deleteSearch', () => { + it('should delete a search', async () => { + mockUserStorage.getItem.mockResolvedValue(JSON.stringify(mockSavedSearches)); + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.savedSearches).toHaveLength(2); + + await act(async () => { + await result.current.deleteSearch('1'); + }); + + expect(result.current.savedSearches).toHaveLength(1); + expect(result.current.savedSearches.find((s) => s.id === '1')).toBeUndefined(); + }); + + it('should track analytics on delete', async () => { + mockUserStorage.getItem.mockResolvedValue(JSON.stringify(mockSavedSearches)); + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.deleteSearch('1'); + }); + + expect(runtime.reportInteraction).toHaveBeenCalledWith('grafana_alerting_saved_search_delete'); + }); + }); + + describe('setDefaultSearch', () => { + it('should set a search as default', async () => { + mockUserStorage.getItem.mockResolvedValue(JSON.stringify(mockSavedSearches)); + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.setDefaultSearch('1'); + }); + + expect(result.current.savedSearches.find((s) => s.id === '1')?.isDefault).toBe(true); + expect(result.current.savedSearches.find((s) => s.id === '2')?.isDefault).toBe(false); + }); + + it('should clear default when null is passed', async () => { + mockUserStorage.getItem.mockResolvedValue(JSON.stringify(mockSavedSearches)); + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.setDefaultSearch(null); + }); + + expect(result.current.savedSearches.every((s) => !s.isDefault)).toBe(true); + }); + + it('should track analytics with correct action', async () => { + mockUserStorage.getItem.mockResolvedValue(JSON.stringify(mockSavedSearches)); + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.setDefaultSearch('1'); + }); + + expect(runtime.reportInteraction).toHaveBeenCalledWith('grafana_alerting_saved_search_set_default', { + action: 'set', + }); + + jest.clearAllMocks(); + + await act(async () => { + await result.current.setDefaultSearch(null); + }); + + expect(runtime.reportInteraction).toHaveBeenCalledWith('grafana_alerting_saved_search_set_default', { + action: 'clear', + }); + }); + }); +}); + +describe('trackSavedSearchApplied', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should track with isDefault true for default searches', () => { + trackSavedSearchApplied({ + id: '1', + name: 'Default', + query: 'state:firing', + isDefault: true, + createdAt: Date.now(), + }); + + expect(runtime.reportInteraction).toHaveBeenCalledWith('grafana_alerting_saved_search_apply', { isDefault: true }); + }); + + it('should track with isDefault false for non-default searches', () => { + trackSavedSearchApplied({ + id: '1', + name: 'Regular', + query: 'state:firing', + isDefault: false, + createdAt: Date.now(), + }); + + expect(runtime.reportInteraction).toHaveBeenCalledWith('grafana_alerting_saved_search_apply', { isDefault: false }); + }); +}); diff --git a/public/app/features/alerting/unified/rule-list/filter/useSavedSearches.ts b/public/app/features/alerting/unified/rule-list/filter/useSavedSearches.ts new file mode 100644 index 00000000000..5c81ce990b3 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/filter/useSavedSearches.ts @@ -0,0 +1,353 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { v4 as uuidv4 } from 'uuid'; + +import { t } from '@grafana/i18n'; +import { reportInteraction } from '@grafana/runtime'; +import { UserStorage } from '@grafana/runtime/internal'; + +import { useAppNotification } from '../../../../../core/copy/appNotification'; +import { logError, logWarning } from '../../Analytics'; +import { isLoading as isLoadingState, isUninitialized, useAsync } from '../../hooks/useAsync'; + +import { SavedSearch, savedSearchSchema, savedSearchesArraySchema, validateSearchName } from './savedSearchesSchema'; + +/** + * Storage key for saved searches in UserStorage. + */ +const SAVED_SEARCHES_STORAGE_KEY = 'savedSearches'; + +/** + * UserStorage instance for saved searches. + * Uses 'alerting' as the service namespace. + */ +const userStorage = new UserStorage('alerting'); + +/** + * Analytics tracking functions for saved search actions. + */ +function trackSavedSearchSave(props: { hasDefault: boolean; totalCount: number }) { + reportInteraction('grafana_alerting_saved_search_save', props); +} + +function trackSavedSearchApply(props: { isDefault: boolean }) { + reportInteraction('grafana_alerting_saved_search_apply', props); +} + +function trackSavedSearchDelete() { + reportInteraction('grafana_alerting_saved_search_delete'); +} + +function trackSavedSearchRename() { + reportInteraction('grafana_alerting_saved_search_rename'); +} + +function trackSavedSearchSetDefault(props: { action: 'set' | 'clear' }) { + reportInteraction('grafana_alerting_saved_search_set_default', props); +} + +export function trackSavedSearchAutoApply() { + reportInteraction('grafana_alerting_saved_search_auto_apply'); +} + +/** + * Validates and parses an array of saved searches using zod schema. + * Returns valid entries and logs warnings for invalid data. + */ +function validateSavedSearches(data: unknown): SavedSearch[] { + const result = savedSearchesArraySchema.safeParse(data); + + if (result.success) { + return result.data; + } + + // If the whole array failed, try to salvage individual valid entries + if (!Array.isArray(data)) { + logWarning('Saved searches data is not an array, returning empty array'); + return []; + } + + logWarning('Saved searches validation failed, filtering invalid entries', { + issues: JSON.stringify(result.error.issues), + }); + + const validEntries: SavedSearch[] = []; + for (const item of data) { + const itemResult = savedSearchSchema.safeParse(item); + if (itemResult.success) { + validEntries.push(itemResult.data); + } + } + return validEntries; +} + +/** + * Sorts saved searches: default search first, then others alphabetically. + * @param searches - Array of saved searches to sort + * @returns Sorted array with default first, then alphabetically by name + */ +function sortSavedSearches(searches: SavedSearch[]): SavedSearch[] { + const defaultSearch = searches.find((s) => s.isDefault); + const collator = new Intl.Collator(undefined, { sensitivity: 'base' }); + const others = searches.filter((s) => !s.isDefault).sort((a, b) => collator.compare(a.name, b.name)); + + return defaultSearch ? [defaultSearch, ...others] : others; +} + +/** + * Loads saved searches from UserStorage and validates the data. + * @returns Promise resolving to an array of valid saved searches + */ +async function loadSavedSearchesFromStorage(): Promise { + const stored = await userStorage.getItem(SAVED_SEARCHES_STORAGE_KEY); + if (!stored) { + return []; + } + + const parsed = JSON.parse(stored); + return validateSavedSearches(parsed); +} + +export async function loadDefaultSavedSearch(): Promise { + const savedSearches = await loadSavedSearchesFromStorage(); + return savedSearches.find((s) => s.isDefault) ?? null; +} + +/** + * Result of the useSavedSearches hook. + */ +export interface UseSavedSearchesResult { + /** List of saved searches */ + savedSearches: SavedSearch[]; + /** Whether the initial load from storage is complete */ + isLoading: boolean; + /** + * Save a new search with the given name and query. + * @param name - The display name for the saved search + * @param query - The search query string + * @throws ValidationError if name is not unique + */ + saveSearch: (name: string, query: string) => Promise; + /** + * Rename an existing saved search. + * @param id - The ID of the search to rename + * @param newName - The new display name + * @throws ValidationError if name is not unique + */ + renameSearch: (id: string, newName: string) => Promise; + /** + * Delete a saved search by ID. + * @param id - The ID of the search to delete + */ + deleteSearch: (id: string) => Promise; + /** + * Set or clear the default search. + * @param id - The ID to set as default, or null to clear + */ + setDefaultSearch: (id: string | null) => Promise; +} + +/** + * Hook for managing saved searches with UserStorage persistence. + * + * Features: + * - Persists saved searches to UserStorage (syncs across devices) + * - Validates data schema on load (filters invalid entries) + * - Validates name uniqueness (case-insensitive) + * - Tracks analytics for all actions + * - Shows error notifications on storage failures + * - Provides auto-apply logic for default search on navigation + * - Per-user session tracking to handle logout/login scenarios + * + * @example + * ```tsx + * const { savedSearches, saveSearch, isLoading } = useSavedSearches(); + * + * // Save current search + * const error = await saveSearch('My Search', currentQuery); + * if (error) { + * // Handle validation error + * } + * + * // Auto-apply default search on mount + * useEffect(() => { + * const defaultSearch = getAutoApplySearch(); + * if (defaultSearch) { + * applySearch(defaultSearch); + * } + * }, []); + * ``` + */ +export function useSavedSearches(): UseSavedSearchesResult { + const [savedSearches, setSavedSearches] = useState([]); + const notifyApp = useAppNotification(); + + // Track whether we've already loaded to prevent double-loading + const hasLoadedRef = useRef(false); + + // Use useAsync for loading state management + const [{ execute: executeLoad }, loadState] = useAsync(loadSavedSearchesFromStorage, []); + const isLoading = isLoadingState(loadState) || isUninitialized(loadState); + + // Load saved searches from storage on mount + useEffect(() => { + if (hasLoadedRef.current) { + return; + } + hasLoadedRef.current = true; + + // Load from UserStorage using async/await pattern + const loadSearches = async () => { + try { + const validated = await executeLoad(); + setSavedSearches(validated); + } catch (error) { + logError(error instanceof Error ? error : new Error('Failed to load saved searches from storage'), { + context: 'useSavedSearches.loadSearches', + }); + notifyApp.error( + t('alerting.saved-searches.error-load-title', 'Failed to load saved searches'), + t( + 'alerting.saved-searches.error-load-description', + 'Your saved searches could not be loaded. Please try refreshing the page.' + ) + ); + } + }; + + loadSearches(); + }, [executeLoad, notifyApp]); + + /** + * Persist saved searches to UserStorage. + */ + const persistSearches = useCallback( + async (searches: SavedSearch[]): Promise => { + try { + await userStorage.setItem(SAVED_SEARCHES_STORAGE_KEY, JSON.stringify(searches)); + } catch (error) { + logError(error instanceof Error ? error : new Error('Failed to save searches'), { + context: 'useSavedSearches.persistSearches', + }); + notifyApp.error( + t('alerting.saved-searches.error-save-title', 'Failed to save'), + t('alerting.saved-searches.error-save-description', 'Your changes could not be saved. Please try again.') + ); + throw error; + } + }, + [notifyApp] + ); + + /** + * Save a new search with the given name and query. + * @throws ValidationError if the name is not unique + */ + const saveSearch = useCallback( + async (name: string, query: string): Promise => { + // Validate name using shared validation function + const validationError = validateSearchName(name, savedSearches); + if (validationError) { + throw { field: 'name' as const, message: validationError }; + } + + const newSearch: SavedSearch = { + id: uuidv4(), + name, + query, + isDefault: false, + createdAt: Date.now(), + }; + + const newSearches = [...savedSearches, newSearch]; + + await persistSearches(newSearches); + setSavedSearches(newSearches); + + // Track analytics + trackSavedSearchSave({ + hasDefault: newSearches.some((s) => s.isDefault), + totalCount: newSearches.length, + }); + }, + [savedSearches, persistSearches] + ); + + /** + * Rename an existing saved search. + * @throws ValidationError if the new name is not unique + */ + const renameSearch = useCallback( + async (id: string, newName: string): Promise => { + // Validate name using shared validation function (excluding current item) + const validationError = validateSearchName(newName, savedSearches, id); + if (validationError) { + throw { field: 'name' as const, message: validationError }; + } + + const newSearches = savedSearches.map((s) => (s.id === id ? { ...s, name: newName } : s)); + + await persistSearches(newSearches); + setSavedSearches(newSearches); + + // Track analytics + trackSavedSearchRename(); + }, + [savedSearches, persistSearches] + ); + + /** + * Delete a saved search by ID. + */ + const deleteSearch = useCallback( + async (id: string): Promise => { + const newSearches = savedSearches.filter((s) => s.id !== id); + + await persistSearches(newSearches); + setSavedSearches(newSearches); + + // Track analytics + trackSavedSearchDelete(); + }, + [savedSearches, persistSearches] + ); + + /** + * Set or clear the default search. + * Pass null to clear the current default. + */ + const setDefaultSearch = useCallback( + async (id: string | null): Promise => { + const newSearches = savedSearches.map((s) => ({ + ...s, + isDefault: id === null ? false : s.id === id, + })); + + await persistSearches(newSearches); + setSavedSearches(newSearches); + + // Track analytics + trackSavedSearchSetDefault({ action: id === null ? 'clear' : 'set' }); + }, + [savedSearches, persistSearches] + ); + + // Sort saved searches: default first, then alphabetically by name + const sortedSavedSearches = useMemo(() => sortSavedSearches(savedSearches), [savedSearches]); + + return { + savedSearches: sortedSavedSearches, + isLoading, + saveSearch, + renameSearch, + deleteSearch, + setDefaultSearch, + }; +} + +/** + * Track when a saved search is applied (called from parent component). + * @param search - The search that was applied + */ +export function trackSavedSearchApplied(search: SavedSearch) { + trackSavedSearchApply({ isDefault: search.isDefault }); +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 53abfa01d14..7a1dd06e31f 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -2666,6 +2666,35 @@ "text-federated": "Federated", "text-provisioned": "Provisioned" }, + "saved-searches": { + "actions-aria-label": "Actions", + "apply-aria-label": "Apply search \"{{name}}\"", + "apply-tooltip": "Apply this search", + "button-label": "Saved searches", + "cancel": "Cancel", + "default-indicator": "Default search", + "delete": "Delete", + "delete-button": "Delete", + "dropdown-aria-label": "Saved searches", + "empty-state": "No saved searches yet", + "error-load-description": "Your saved searches could not be loaded. Please try refreshing the page.", + "error-load-title": "Failed to load saved searches", + "error-name-duplicate": "A saved search with this name already exists", + "error-name-required": "Name is required", + "error-rename-description": "Your changes could not be saved. Please try again.", + "error-rename-title": "Failed to rename", + "error-save-description": "Your changes could not be saved. Please try again.", + "error-save-title": "Failed to save", + "list-aria-label": "Saved searches list", + "name-placeholder": "Enter a name...", + "remove-default": "Remove default", + "rename": "Rename", + "rename-button": "Rename", + "save-button": "Save", + "save-current-search": "Save current search", + "save-disabled-tooltip": "Enter a search query first", + "set-default": "Set as default" + }, "search": { "property": { "data-source": "Data source", From 56dd1ca86761834ca3076c5f9f07d345029684e0 Mon Sep 17 00:00:00 2001 From: Anna Urbiztondo Date: Fri, 19 Dec 2025 15:40:33 +0100 Subject: [PATCH 053/229] Docs: Note for File Provisioning (#115630) Note --- .../provision-resources/file-path-setup.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/sources/as-code/observability-as-code/provision-resources/file-path-setup.md b/docs/sources/as-code/observability-as-code/provision-resources/file-path-setup.md index 4c0d22c3be9..ca71a0c0d9c 100644 --- a/docs/sources/as-code/observability-as-code/provision-resources/file-path-setup.md +++ b/docs/sources/as-code/observability-as-code/provision-resources/file-path-setup.md @@ -155,15 +155,21 @@ To set up synchronization: 1. You can repeat this process for up to 10 connections. +{{< admonition type="note" >}} + +Optionally, you can export any unmanaged resources into the provisioned folder. See how in [Synchronize with external storage](#synchronize-with-external-storage). + +{{< /admonition >}} + ### Synchronize with external storage -After this one time step, all future updates are automatically saved to the local file path and provisioned back to the instance. +In this step you proceed to synchronize the resources selected in the previous step. Optionally, you can check the **Migrate existing resources** box to migrate your unmanaged dashboards to the provisioned folder. -During the initial synchronization, your dashboards will be temporarily unavailable. No data or configurations will be lost. +Select **Begin synchronization** to start the process. After this one time step, all future updates are automatically saved to the local file path and provisioned back to the instance. + +Note that during the initial synchronization, your dashboards will be temporarily unavailable. No data or configurations will be lost. How long the process takes depends upon the number of resources involved. -Select **Begin synchronization** to start the process. - ### Choose additional settings If you wish, you can make any files synchronized as as **Read only** so no changes can be made to the resources through Grafana. From b2d6bb7a05807fb5d817c463c32bb64ac3338c76 Mon Sep 17 00:00:00 2001 From: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Fri, 19 Dec 2025 09:26:36 -0600 Subject: [PATCH 054/229] logsdrilldowndefaultcolumns: require plugins:write for non GET operations (#115639) chore: require plugins:write --- pkg/registry/apps/logsdrilldown/authorizer.go | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/pkg/registry/apps/logsdrilldown/authorizer.go b/pkg/registry/apps/logsdrilldown/authorizer.go index f485937121d..b9ba63a3cd2 100644 --- a/pkg/registry/apps/logsdrilldown/authorizer.go +++ b/pkg/registry/apps/logsdrilldown/authorizer.go @@ -24,26 +24,38 @@ func GetAuthorizer() authorizer.Authorizer { return authorizer.DecisionDeny, "valid user is required", err } + // check if is admin + if u.GetIsGrafanaAdmin() { + return authorizer.DecisionAllow, "isGrafanaAdmin", nil + } + // Auth handling for LogsDrilldownDefaults resource if attr.GetResource() == "logsdrilldowndefaults" { // Allow list and get for everyone if attr.GetVerb() == "list" || attr.GetVerb() == "get" { return authorizer.DecisionAllow, "", nil } - // Only allow admins to update (create, update, patch, delete) - if u.GetIsGrafanaAdmin() { - return authorizer.DecisionAllow, "", nil - } // Deny all other operations for non-admins return authorizer.DecisionDeny, "admin access required", nil } - // check if is admin - if u.GetIsGrafanaAdmin() { - return authorizer.DecisionAllow, "", nil + p := u.GetPermissions() + + // Auth handling for Logs Drilldown default columns + if attr.GetResource() == "logsdrilldowndefaultcolumns" { + // Allow get for all users + if attr.GetVerb() == "get" { + return authorizer.DecisionAllow, "", nil + } + // require plugins:write permissions for other operations + _, ok := p[accesscontrol.PluginRolePrefix+"write"] + if ok { + return authorizer.DecisionAllow, "user has plugins:write", nil + } else { + return authorizer.DecisionDeny, "user missing plugins:write", nil + } } - p := u.GetPermissions() if len(p) == 0 { return authorizer.DecisionDeny, "no permissions", nil } From 7812f783bb44e10cb0aea4cbc36af4c3ca47d854 Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Fri, 19 Dec 2025 17:36:46 +0200 Subject: [PATCH 055/229] Provisioning: Enable editing dashboard via JSON model (#115420) * Provisioning: Enable save for json model changes * Do not pass props * Simplify logic and fix warnings * add tests * Show diff for json changes * Add try/catch --- eslint-suppressions.json | 5 -- .../dashboard-scene/scene/DashboardScene.tsx | 41 ++++++++--- .../serialization/DashboardSceneSerializer.ts | 21 ++++-- .../settings/JsonModelEditView.tsx | 15 +++- .../SaveProvisionedDashboardForm.test.tsx | 72 ++++++++++++++++++- .../SaveProvisionedDashboardForm.tsx | 21 +++--- public/locales/en-US/grafana.json | 2 +- 7 files changed, 147 insertions(+), 30 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 36eb78aaa72..597f2b94a1a 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1911,11 +1911,6 @@ "count": 1 } }, - "public/app/features/dashboard-scene/settings/JsonModelEditView.tsx": { - "react/no-unescaped-entities": { - "count": 2 - } - }, "public/app/features/dashboard-scene/settings/variables/VariableEditableElement.tsx": { "react-hooks/rules-of-hooks": { "count": 4 diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 82a20dbcd52..7ddd7c4e779 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -59,6 +59,7 @@ import { gridItemToGridLayoutItemKind } from '../serialization/layoutSerializers import { getElement } from '../serialization/layoutSerializers/utils'; import { buildGridItemForPanel, transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { gridItemToPanel } from '../serialization/transformSceneToSaveModel'; +import { JsonModelEditView } from '../settings/JsonModelEditView'; import { DecoratedRevisionModel } from '../settings/VersionsEditView'; import { DashboardEditView } from '../settings/utils'; import { historySrv } from '../settings/version-history/HistorySrv'; @@ -855,30 +856,54 @@ export class DashboardScene extends SceneObjectBase impleme return this.serializer.getSaveModel(this); } - // Get the dashboard in native K8s form (using the appropriate apiVersion) - getSaveResource(options: SaveDashboardAsOptions): ResourceForCreate { + // Helper method to build K8s resource structure + private buildResourceForCreate(spec: Dashboard | DashboardV2Spec, isNew: boolean): ResourceForCreate { const { meta } = this.state; - const spec = this.getSaveAsModel(options); - - const apiVersion = this.serializer instanceof V2DashboardSerializer ? 'v2beta1' : 'v1beta1'; // get from the dashboard? + const apiVersion = this.serializer instanceof V2DashboardSerializer ? 'v2beta1' : 'v1beta1'; return { apiVersion: `dashboard.grafana.app/${apiVersion}`, kind: 'Dashboard', metadata: { ...meta.k8s, - name: options.isNew ? undefined : (meta.uid ?? meta.k8s?.name), - generateName: options.isNew ? 'd' : undefined, + name: isNew ? undefined : (meta.uid ?? meta.k8s?.name), + generateName: isNew ? 'd' : undefined, }, spec, }; } + // Get the dashboard in native K8s form (using the appropriate apiVersion) + getSaveResource(options: SaveDashboardAsOptions): ResourceForCreate { + const spec = this.getSaveAsModel(options); + return this.buildResourceForCreate(spec, options.isNew ?? false); + } + + // Wrap a raw dashboard spec in K8s resource format + // Used by JSON model editor for Git sync dashboards + getSaveResourceFromSpec(rawSpec: Dashboard | DashboardV2Spec): ResourceForCreate { + return this.buildResourceForCreate(rawSpec, false); + } + + // Get raw JSON from JSON model editor if currently active + // Returns undefined if not in JSON editor mode or if JSON is invalid + getRawJsonFromEditor(): Dashboard | DashboardV2Spec | undefined { + if (this.state.editview instanceof JsonModelEditView) { + try { + return JSON.parse(this.state.editview.state.jsonText); + } catch { + return undefined; + } + } + return undefined; + } + getSaveAsModel(options: SaveDashboardAsOptions): Dashboard | DashboardV2Spec { return this.serializer.getSaveAsModel(this, options); } getDashboardChanges(saveTimeRange?: boolean, saveVariables?: boolean, saveRefresh?: boolean): DashboardChangeInfo { - return this.serializer.getDashboardChangesFromScene(this, { saveTimeRange, saveVariables, saveRefresh }); + const rawJson = this.getRawJsonFromEditor(); + return this.serializer.getDashboardChangesFromScene(this, { saveTimeRange, saveVariables, saveRefresh, rawJson }); } getManagerKind(): ManagerKind | undefined { diff --git a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts index cede8f605a5..130c5450bf0 100644 --- a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts @@ -46,6 +46,7 @@ export interface DashboardSceneSerializerLike DashboardChangeInfo; onSaveComplete(saveModel: T, result: SaveDashboardResponseDTO): void; @@ -184,9 +185,15 @@ export class V1DashboardSerializer getDashboardChangesFromScene( scene: DashboardScene, - options: { saveTimeRange?: boolean; saveVariables?: boolean; saveRefresh?: boolean } + options: { + saveTimeRange?: boolean; + saveVariables?: boolean; + saveRefresh?: boolean; + rawJson?: Dashboard | DashboardV2Spec; + } ) { - const changedSaveModel = this.getSaveModel(scene); + const changedSaveModel = + options.rawJson && !isDashboardV2Spec(options.rawJson) ? options.rawJson : this.getSaveModel(scene); const changeInfo = getRawDashboardChanges( this.initialSaveModel!, changedSaveModel, @@ -396,9 +403,15 @@ export class V2DashboardSerializer getDashboardChangesFromScene( scene: DashboardScene, - options: { saveTimeRange?: boolean; saveVariables?: boolean; saveRefresh?: boolean } + options: { + saveTimeRange?: boolean; + saveVariables?: boolean; + saveRefresh?: boolean; + rawJson?: Dashboard | DashboardV2Spec; + } ) { - const changedSaveModel = this.getSaveModel(scene); + const changedSaveModel = + options.rawJson && isDashboardV2Spec(options.rawJson) ? options.rawJson : this.getSaveModel(scene); const changeInfo = getRawDashboardV2Changes( this.initialSaveModel!, changedSaveModel, diff --git a/public/app/features/dashboard-scene/settings/JsonModelEditView.tsx b/public/app/features/dashboard-scene/settings/JsonModelEditView.tsx index 30979075d2a..99522077ae3 100644 --- a/public/app/features/dashboard-scene/settings/JsonModelEditView.tsx +++ b/public/app/features/dashboard-scene/settings/JsonModelEditView.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { GrafanaTheme2, PageLayoutType } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { SceneComponentProps, SceneObjectBase, sceneUtils } from '@grafana/scenes'; +import { SceneComponentProps, SceneObjectBase, SceneObjectRef, sceneUtils } from '@grafana/scenes'; import { Dashboard } from '@grafana/schema'; import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { Alert, Box, Button, CodeEditor, Stack, useStyles2 } from '@grafana/ui'; @@ -11,8 +11,10 @@ import { Page } from 'app/core/components/Page/Page'; import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; import { isDashboardV2Spec } from 'app/features/dashboard/api/utils'; import { getPrettyJSON } from 'app/features/inspector/utils/utils'; +import { useIsProvisionedNG } from 'app/features/provisioning/hooks/useIsProvisionedNG'; import { DashboardDataDTO, SaveDashboardResponseDTO } from 'app/types/dashboard'; +import { SaveDashboardDrawer } from '../saving/SaveDashboardDrawer'; import { NameAlreadyExistsError, isNameExistsError, @@ -105,12 +107,21 @@ function JsonModelEditViewComponent({ model }: SceneComponentProps { + if (isProvisionedNG) { + const drawer = new SaveDashboardDrawer({ + dashboardRef: new SceneObjectRef(dashboard), + }); + dashboard.setState({ overlay: drawer }); + return; + } + const result = await onSaveDashboard(dashboard, { folderUid: dashboard.state.meta.folderUid, overwrite, @@ -136,7 +147,7 @@ function JsonModelEditViewComponent({ model }: SceneComponentProps {overwrite ? ( - 'Save and overwrite' + Save and overwrite ) : ( Save changes )} diff --git a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx index bca9ca1939c..69a94c5e9ae 100644 --- a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx +++ b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx @@ -9,7 +9,7 @@ import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScen import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv'; import { useCreateOrUpdateRepositoryFile } from 'app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile'; -import { SaveProvisionedDashboardForm, Props } from './SaveProvisionedDashboardForm'; +import { Props, SaveProvisionedDashboardForm } from './SaveProvisionedDashboardForm'; jest.mock('@grafana/runtime', () => { const actual = jest.requireActual('@grafana/runtime'); @@ -118,6 +118,7 @@ function setup(props: Partial = {}) { closeModal: jest.fn(), getSaveAsModel: jest.fn().mockReturnValue(mockDashboard), setManager: jest.fn(), + getRawJsonFromEditor: jest.fn().mockReturnValue(undefined), } as unknown as DashboardScene, drawer: { onClose: jest.fn(), @@ -291,6 +292,7 @@ describe('SaveProvisionedDashboardForm', () => { closeModal: jest.fn(), getSaveResource: jest.fn().mockReturnValue(updatedDashboard), setManager: jest.fn(), + getRawJsonFromEditor: jest.fn().mockReturnValue(undefined), } as unknown as DashboardScene, }); @@ -385,6 +387,7 @@ describe('SaveProvisionedDashboardForm', () => { closeModal: jest.fn(), getSaveAsModel: jest.fn().mockReturnValue({}), setManager: jest.fn(), + getRawJsonFromEditor: jest.fn().mockReturnValue(undefined), } as unknown as DashboardScene, }); @@ -431,6 +434,7 @@ describe('SaveProvisionedDashboardForm', () => { closeModal: jest.fn(), getSaveAsModel: jest.fn().mockReturnValue({}), setManager: jest.fn(), + getRawJsonFromEditor: jest.fn().mockReturnValue(undefined), } as unknown as DashboardScene, }); @@ -445,4 +449,70 @@ describe('SaveProvisionedDashboardForm', () => { expect(saveButton).toBeEnabled(); }); }); + + it('should save dashboard with raw JSON from editor', async () => { + const mockAction = jest.fn(); + const mockRequest = { ...mockRequestBase, isSuccess: true }; + (useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([mockAction, mockRequest]); + + const rawJson = JSON.stringify({ + title: 'Raw JSON Dashboard', + panels: [], + schemaVersion: 36, + }); + + const dashboardFromRawJson = { + apiVersion: 'dashboard.grafana.app/v1alpha1', + kind: 'Dashboard', + metadata: { + generateName: 'p', + name: undefined, + }, + spec: { + title: 'Raw JSON Dashboard', + panels: [], + schemaVersion: 36, + }, + }; + + const { user } = setup({ + dashboard: { + useState: () => ({ + meta: { + folderUid: 'folder-uid', + slug: 'test-dashboard', + }, + title: 'Test Dashboard', + description: 'Test Description', + isDirty: false, + }), + setState: jest.fn(), + closeModal: jest.fn(), + getSaveAsModel: jest.fn().mockReturnValue({}), + getSaveResource: jest.fn().mockReturnValue(dashboardFromRawJson), + getSaveResourceFromSpec: jest.fn().mockReturnValue(dashboardFromRawJson), + setManager: jest.fn(), + getRawJsonFromEditor: jest.fn().mockReturnValue(rawJson), + } as unknown as DashboardScene, + }); + + const saveButton = screen.getByRole('button', { name: /save/i }); + expect(saveButton).toBeEnabled(); + + const commentInput = screen.getByRole('textbox', { name: /comment/i }); + await user.clear(commentInput); + await user.type(commentInput, 'Save with raw JSON'); + + await user.click(saveButton); + + await waitFor(() => { + expect(mockAction).toHaveBeenCalledWith({ + ref: 'dashboard/2023-01-01-abcde', + name: 'test-repo', + path: 'test-dashboard.json', + message: 'Save with raw JSON', + body: dashboardFromRawJson, + }); + }); + }); }); diff --git a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx index a2df43a1f95..3fee476b189 100644 --- a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx +++ b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx @@ -65,8 +65,9 @@ export function SaveProvisionedDashboardForm({ register, formState: { dirtyFields }, } = methods; - // button enabled if form comment is dirty or dashboard state is dirty - const isDirtyState = Boolean(dirtyFields.comment) || isDirty; + // button enabled if form comment is dirty or dashboard state is dirty or raw JSON was provided from editor + const rawDashboardJSON = dashboard.getRawJsonFromEditor(); + const isDirtyState = Boolean(dirtyFields.comment) || isDirty || Boolean(rawDashboardJSON); const [workflow, ref, path] = watch(['workflow', 'ref', 'path']); // Update the form if default values change @@ -191,13 +192,15 @@ export function SaveProvisionedDashboardForm({ const message = comment || `Save dashboard: ${dashboard.state.title}`; - const body = dashboard.getSaveResource({ - isNew, - title, - description, - copyTags, - saveAsCopy, - }); + const body = rawDashboardJSON + ? dashboard.getSaveResourceFromSpec(rawDashboardJSON) + : dashboard.getSaveResource({ + isNew, + title, + description, + copyTags, + saveAsCopy, + }); reportInteraction('grafana_provisioning_dashboard_save_submitted', { workflow, diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 7a1dd06e31f..8e4ed2c7b20 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -6210,7 +6210,7 @@ "title-someone-else-has-updated-this-dashboard": "Someone else has updated this dashboard", "would-still-dashboard": "Would you still like to save this dashboard?" }, - "save-and-overwrite": "'Save and overwrite'" + "save-and-overwrite": "Save and overwrite" }, "library-viz-panel-info": { "last-edited": "{{timeAgo}} by ", From aa69d97f1eb6a871fa1d7dc3d82f207c1754652d Mon Sep 17 00:00:00 2001 From: Kristina Demeshchik Date: Fri, 19 Dec 2025 10:38:22 -0500 Subject: [PATCH 056/229] Variables: Show variable reference instead of interpolated datasource in query variable editor (#115624) show variable ref --- .../settings/variables/editors/QueryVariableEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx index c32bc8eda8d..3f0583d3245 100644 --- a/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx +++ b/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx @@ -265,7 +265,7 @@ export function Editor({ variable }: { variable: QueryVariable }) { htmlFor="data-source-picker" noMargin > - + {selectedDatasource && VariableQueryEditor && ( From 0e4b1c7b1e23c5ad40669fdd6ee4b28b6e33cfb4 Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Fri, 19 Dec 2025 17:57:45 +0200 Subject: [PATCH 057/229] Provisioning: Fix error loop in synchronise step (#115570) * Refactor requiresMigration * Remove InlineSecureValueWarning * Prevent error loop * Fix error loop * Cleanup * i18n --- .../utils/createOnCacheEntryAdded.ts | 1 + .../provisioning/Config/ConfigForm.tsx | 2 -- public/app/features/provisioning/HomePage.tsx | 2 -- .../provisioning/Job/FinishedJobStatus.tsx | 5 +++ .../features/provisioning/Job/JobStatus.tsx | 21 ++++++++---- .../Repository/RepositoryStatusPage.tsx | 9 ++++-- .../Wizard/hooks/useResourceStats.ts | 18 ++--------- .../components/InlineSecureValueWarning.tsx | 32 ------------------- .../features/provisioning/utils/httpUtils.ts | 3 ++ public/locales/en-US/grafana.json | 4 ++- 10 files changed, 37 insertions(+), 60 deletions(-) delete mode 100644 public/app/features/provisioning/components/InlineSecureValueWarning.tsx diff --git a/public/app/api/clients/provisioning/utils/createOnCacheEntryAdded.ts b/public/app/api/clients/provisioning/utils/createOnCacheEntryAdded.ts index c3c44ede267..0d39d4fa402 100644 --- a/public/app/api/clients/provisioning/utils/createOnCacheEntryAdded.ts +++ b/public/app/api/clients/provisioning/utils/createOnCacheEntryAdded.ts @@ -58,6 +58,7 @@ export function createOnCacheEntryAdded(resourceName: string) { }); } catch (error) { console.error('Error in onCacheEntryAdded:', error); + return; } await cacheEntryRemoved; diff --git a/public/app/features/provisioning/Config/ConfigForm.tsx b/public/app/features/provisioning/Config/ConfigForm.tsx index 9c86403fee6..3b1aebc1911 100644 --- a/public/app/features/provisioning/Config/ConfigForm.tsx +++ b/public/app/features/provisioning/Config/ConfigForm.tsx @@ -28,7 +28,6 @@ import { FormPrompt } from 'app/core/components/FormPrompt/FormPrompt'; import { DeleteRepositoryButton } from '../Repository/DeleteRepositoryButton'; import { TokenPermissionsInfo } from '../Shared/TokenPermissionsInfo'; import { getGitProviderFields, getLocalProviderFields } from '../Wizard/fields'; -import { InlineSecureValueWarning } from '../components/InlineSecureValueWarning'; import { PROVISIONING_URL } from '../constants'; import { useCreateOrUpdateRepository } from '../hooks/useCreateOrUpdateRepository'; import { RepositoryFormData } from '../types'; @@ -178,7 +177,6 @@ export function ConfigForm({ data }: ConfigFormProps) { {gitFields && ( <> - } > - diff --git a/public/app/features/provisioning/Job/JobStatus.tsx b/public/app/features/provisioning/Job/JobStatus.tsx index 02e0b733bbb..7aec776f8aa 100644 --- a/public/app/features/provisioning/Job/JobStatus.tsx +++ b/public/app/features/provisioning/Job/JobStatus.tsx @@ -1,8 +1,11 @@ +import { useEffect } from 'react'; + import { Trans, t } from '@grafana/i18n'; import { Spinner, Stack, Text } from '@grafana/ui'; import { Job, useListJobQuery } from 'app/api/clients/provisioning/v0alpha1'; import { StepStatusInfo } from '../Wizard/types'; +import { getErrorMessage } from '../utils/httpUtils'; import { FinishedJobStatus } from './FinishedJobStatus'; import { JobContent } from './JobContent'; @@ -25,6 +28,18 @@ export function JobStatus({ jobType, watch, onStatusChange }: JobStatusProps) { const activeQueryCompleted = !activeQuery.isUninitialized && !activeQuery.isLoading; const shouldCheckFinishedJobs = activeQueryCompleted && !activeJob && !!repoLabel; + useEffect(() => { + if (activeQuery.isError) { + onStatusChange?.({ + status: 'error', + error: { + title: t('provisioning.job-status.title.error-fetching-active-job', 'Error fetching active job'), + message: getErrorMessage(activeQuery.error), + }, + }); + } + }, [activeQuery.isError, activeQuery.error, onStatusChange]); + if (activeQuery.isLoading) { return ( @@ -37,12 +52,6 @@ export function JobStatus({ jobType, watch, onStatusChange }: JobStatusProps) { } if (activeQuery.isError) { - onStatusChange?.({ - status: 'error', - error: { - title: t('provisioning.job-status.title.error-fetching-active-job', 'Error fetching active job'), - }, - }); return null; } diff --git a/public/app/features/provisioning/Repository/RepositoryStatusPage.tsx b/public/app/features/provisioning/Repository/RepositoryStatusPage.tsx index a8984eaa51f..e18e47500da 100644 --- a/public/app/features/provisioning/Repository/RepositoryStatusPage.tsx +++ b/public/app/features/provisioning/Repository/RepositoryStatusPage.tsx @@ -10,8 +10,8 @@ import { Page } from 'app/core/components/Page/Page'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { isNotFoundError } from 'app/features/alerting/unified/api/util'; -import { InlineSecureValueWarning } from '../components/InlineSecureValueWarning'; import { PROVISIONING_URL } from '../constants'; +import { getErrorMessage } from '../utils/httpUtils'; import { RepositoryActions } from './RepositoryActions'; import { RepositoryOverview } from './RepositoryOverview'; @@ -36,6 +36,7 @@ export default function RepositoryStatusPage() { const tab = queryParams['tab'] ?? TabSelection.Overview; const notFound = query.isError && isNotFoundError(query.error); + const hasError = query.isError && !notFound; const tabInfo = useMemo>( () => [ @@ -63,7 +64,11 @@ export default function RepositoryStatusPage() { actions={data && } > - + {hasError && ( + + {getErrorMessage(query.error)} + + )} {notFound ? ( 0; - - // Calculate final requiresMigration based on sync target and user selection - // For instance sync: always use baseRequiresMigration (checkbox is disabled and always true) + // Calculate requiresMigration based on sync target and user selection + // For instance sync: migrate if there are resources (checkbox is disabled and always true) // For folder sync: only migrate if user explicitly opts in via checkbox - const requiresMigration = useMemo(() => { - if (syncTarget === 'instance') { - return baseRequiresMigration; - } - if (syncTarget === 'folder') { - return migrateResources ?? false; - } - return baseRequiresMigration; - }, [syncTarget, baseRequiresMigration, migrateResources]); - + const requiresMigration = syncTarget === 'instance' ? resourceCount > 0 : (migrateResources ?? false); const shouldSkipSync = (resourceCount === 0 || syncTarget === 'folder') && fileCount === 0; // Format display strings diff --git a/public/app/features/provisioning/components/InlineSecureValueWarning.tsx b/public/app/features/provisioning/components/InlineSecureValueWarning.tsx deleted file mode 100644 index 9ed4a23b7b1..00000000000 --- a/public/app/features/provisioning/components/InlineSecureValueWarning.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { t } from '@grafana/i18n'; -import { Alert } from '@grafana/ui'; -import { Repository } from 'app/api/clients/provisioning/v0alpha1'; - -interface Props { - repo?: Repository; - items?: Repository[]; -} - -// TODO: remove this after 12.2 -export function InlineSecureValueWarning({ repo, items }: Props) { - const isRepoValid = (r?: Repository) => r?.spec?.type === 'local' || !!r?.secure?.token?.name; - - if (isRepoValid(repo)) { - return null; - } - - // When a list is passed in, show an error if anything is missing - if (items?.every(isRepoValid)) { - return null; - } - - return ( - - ); -} diff --git a/public/app/features/provisioning/utils/httpUtils.ts b/public/app/features/provisioning/utils/httpUtils.ts index 6e9cbc4e933..3fb6bd7704f 100644 --- a/public/app/features/provisioning/utils/httpUtils.ts +++ b/public/app/features/provisioning/utils/httpUtils.ts @@ -1,4 +1,5 @@ import { t } from '@grafana/i18n'; +import { isFetchError } from '@grafana/runtime'; import { HttpError, isHttpError } from '../guards'; @@ -187,6 +188,8 @@ export function getErrorMessage(err: unknown) { } else if (err.message) { errorMessage = err.message; } + } else if (isFetchError(err)) { + errorMessage = err.data.message; } return errorMessage; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 8e4ed2c7b20..9678b91e619 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -11985,7 +11985,6 @@ "resource-not-found": "Resource not found. Please check the URL or repository.", "unsupported-repository-type": "Unsupported repository type: {{repositoryType}}" }, - "inline-secure-values-warning": "You need to save your access tokens again due to a system update", "instance-sync-deprecation": { "message": "Instance sync is currently not fully supported and breaks library panels and alerts. To use library panels and alerts, disconnect your repository and reconnect it using folder sync instead.", "title": "Instance sync is not fully supported" @@ -12095,6 +12094,9 @@ "webhook-last-event": "Last Event:", "webhook-url": "View Webhook" }, + "repository-status": { + "error": "Failed to load repository" + }, "repository-status-page": { "back-to-repositories": "Back to repositories", "cleaning-up-resources": "Cleaning up repository resources", From fa73caf6c84062d9437c577d21e5a65f806af386 Mon Sep 17 00:00:00 2001 From: Collin Fingar Date: Fri, 19 Dec 2025 11:30:24 -0500 Subject: [PATCH 058/229] Snapshots: Fix V2 Snapshot data coupling (#115278) * Snapshots: Potential fix for rendering V2 snaps * removing comments * Added unit test --- .../transformSceneToSaveModelSchemaV2.test.ts | 102 +++++++++++++++++- .../transformSceneToSaveModelSchemaV2.ts | 72 ++++++++++--- 2 files changed, 160 insertions(+), 14 deletions(-) diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts index 6d0c450add3..4dcc732fb01 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts @@ -1,4 +1,4 @@ -import { VariableRefresh } from '@grafana/data'; +import { VariableRefresh, PanelData, LoadingState, toDataFrame, FieldType, getDefaultTimeRange } from '@grafana/data'; import { config } from '@grafana/runtime'; import { AdHocFiltersVariable, @@ -18,6 +18,8 @@ import { VizPanel, SceneDataQuery, SceneQueryRunner, + SceneDataTransformer, + SceneDataNode, sceneUtils, dataLayers, } from '@grafana/scenes'; @@ -33,6 +35,7 @@ import { TabsLayoutSpec, defaultDataQueryKind, } from '@grafana/schema/dist/esm/schema/dashboard/v2'; +import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types'; import { DashboardEditPane } from '../edit-pane/DashboardEditPane'; import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer'; @@ -954,6 +957,103 @@ describe('getVizPanelQueries', () => { expect(result[1].spec.query.group).toBe('prometheus'); expect(result[1].spec.query.version).toBe('v0'); }); + + describe('snapshot mode', () => { + it('should return empty queries when isSnapshot is true but panel has no data provider', () => { + const vizPanel = new VizPanel({ + key: 'panel-1', + pluginId: 'timeseries', + // No $data provider + }); + + const result = getVizPanelQueries(vizPanel, undefined, true); + expect(result).toEqual([]); + }); + + it('should create snapshot query from SceneQueryRunner data when isSnapshot is true', () => { + const mockDataFrame = toDataFrame({ + name: 'test-series', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000] }, + { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + ], + }); + + const panelData: PanelData = { + series: [mockDataFrame], + state: LoadingState.Done, + timeRange: getDefaultTimeRange(), + }; + + const queryRunner = new SceneQueryRunner({ + queries: [], + data: panelData, + }); + + const vizPanel = new VizPanel({ + key: 'panel-1', + pluginId: 'timeseries', + $data: queryRunner, + }); + + const result = getVizPanelQueries(vizPanel, undefined, true); + + expect(result).toHaveLength(1); + expect(result[0].kind).toBe('PanelQuery'); + expect(result[0].spec.refId).toBe('A'); + expect(result[0].spec.hidden).toBe(false); + expect(result[0].spec.query.kind).toBe('DataQuery'); + expect(result[0].spec.query.version).toBe(defaultDataQueryKind().version); + expect(result[0].spec.query.group).toBe('grafana'); + expect(result[0].spec.query.datasource).toEqual({ name: 'grafana' }); + expect(result[0].spec.query.spec.queryType).toBe(GrafanaQueryType.Snapshot); + expect(result[0].spec.query.spec.snapshot).toBeDefined(); + expect(result[0].spec.query.spec.snapshot).toHaveLength(1); + expect(result[0].spec.query.spec.snapshot[0].schema?.fields).toBeDefined(); + }); + + it('should create snapshot query from SceneDataTransformer data when isSnapshot is true', () => { + const mockDataFrame = toDataFrame({ + name: 'transformed-series', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000] }, + { name: 'transformed', type: FieldType.number, values: [10, 20] }, + ], + }); + + const panelData: PanelData = { + series: [mockDataFrame], + state: LoadingState.Done, + timeRange: getDefaultTimeRange(), + }; + + const dataNode = new SceneDataNode({ + data: panelData, + }); + + const dataTransformer = new SceneDataTransformer({ + $data: dataNode, + transformations: [], + }); + + const vizPanel = new VizPanel({ + key: 'panel-1', + pluginId: 'timeseries', + $data: dataTransformer, + }); + + const result = getVizPanelQueries(vizPanel, undefined, true); + + expect(result).toHaveLength(1); + expect(result[0].kind).toBe('PanelQuery'); + expect(result[0].spec.query.kind).toBe('DataQuery'); + expect(result[0].spec.query.spec.queryType).toBe(GrafanaQueryType.Snapshot); + expect(result[0].spec.query.spec.snapshot).toBeDefined(); + expect(result[0].spec.query.spec.snapshot).toHaveLength(1); + // Verify it gets data from the nested $data (SceneDataNode) not the transformer + expect(result[0].spec.query.spec.snapshot[0].schema?.fields).toBeDefined(); + }); + }); }); function getMinimalSceneState(body: DashboardLayoutManager): Partial { diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts index 4a63c62af93..0ef2a5e5f05 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts @@ -13,8 +13,10 @@ import { SceneVariableSet, VizPanel, } from '@grafana/scenes'; -import { DataSourceRef, VariableRefresh } from '@grafana/schema'; +import { DataSourceRef } from '@grafana/schema'; import { sortedDeepCloneWithoutNulls } from 'app/core/utils/object'; +import { getPanelDataFrames } from 'app/features/dashboard/components/HelpWizard/utils'; +import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types'; import { Spec as DashboardV2Spec, @@ -127,7 +129,7 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps // EOF variables // elements - elements: getElements(scene, dsReferencesMapping), + elements: getElements(scene, dsReferencesMapping, isSnapshot), // EOF elements // annotations @@ -170,17 +172,18 @@ function getLiveNow(state: DashboardSceneState) { return Boolean(liveNow); } -function getElements(scene: DashboardScene, dsReferencesMapping?: DSReferencesMapping) { +function getElements(scene: DashboardScene, dsReferencesMapping?: DSReferencesMapping, isSnapshot = false) { const panels = scene.state.body.getVizPanels() ?? []; const panelsArray = panels.map((vizPanel) => { - return vizPanelToSchemaV2(vizPanel, dsReferencesMapping); + return vizPanelToSchemaV2(vizPanel, dsReferencesMapping, isSnapshot); }); return createElements(panelsArray, scene); } export function vizPanelToSchemaV2( vizPanel: VizPanel, - dsReferencesMapping?: DSReferencesMapping + dsReferencesMapping?: DSReferencesMapping, + isSnapshot = false ): PanelKind | LibraryPanelKind { if (isLibraryPanel(vizPanel)) { const behavior = getLibraryPanelBehavior(vizPanel)!; @@ -216,7 +219,7 @@ export function vizPanelToSchemaV2( data: { kind: 'QueryGroup', spec: { - queries: getVizPanelQueries(vizPanel, dsReferencesMapping), + queries: getVizPanelQueries(vizPanel, dsReferencesMapping, isSnapshot), transformations: getVizPanelTransformations(vizPanel), queryOptions: getVizPanelQueryOptions(vizPanel), }, @@ -290,9 +293,51 @@ function getPanelLinks(panel: VizPanel): DataLink[] { return []; } -export function getVizPanelQueries(vizPanel: VizPanel, dsReferencesMapping?: DSReferencesMapping): PanelQueryKind[] { +export function getVizPanelQueries( + vizPanel: VizPanel, + dsReferencesMapping?: DSReferencesMapping, + isSnapshot = false +): PanelQueryKind[] { const queries: PanelQueryKind[] = []; const queryRunner = getQueryRunnerFor(vizPanel); + + if (isSnapshot) { + const dataProvider = vizPanel.state.$data; + if (!dataProvider) { + return queries; + } + + let snapshotData = getPanelDataFrames(dataProvider.state.data); + if (dataProvider instanceof SceneDataTransformer) { + snapshotData = getPanelDataFrames(dataProvider.state.$data!.state.data); + } + + const snapshotQuery: DataQueryKind = { + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: 'grafana', + datasource: { + name: 'grafana', + }, + spec: { + queryType: GrafanaQueryType.Snapshot, + snapshot: snapshotData, + }, + }; + + queries.push({ + kind: 'PanelQuery', + spec: { + query: snapshotQuery, + refId: 'A', + hidden: false, + }, + }); + + return queries; + } + + // Regular query handling (non-snapshot) const vizPanelQueries = queryRunner?.state.queries; if (vizPanelQueries) { @@ -631,15 +676,16 @@ export function trimDashboardForSnapshot(title: string, time: TimeRange, dash: D if (spec.variables) { spec.variables.forEach((variable) => { - if ('query' in variable) { - variable.query = ''; + if ('query' in variable.spec) { + variable.spec.query = ''; } - if ('options' in variable && 'current' in variable) { - variable.options = variable.current && !isEmptyObject(variable.current) ? [variable.current] : []; + if ('options' in variable.spec && 'current' in variable.spec) { + variable.spec.options = + variable.spec.current && !isEmptyObject(variable.spec.current) ? [variable.spec.current] : []; } - if ('refresh' in variable) { - variable.refresh = VariableRefresh.never; + if ('refresh' in variable.spec) { + variable.spec.refresh = 'never'; } }); } From 8b316cca250d6976893a655fa51874f73f0aef10 Mon Sep 17 00:00:00 2001 From: Rodrigo Vasconcelos de Barros Date: Fri, 19 Dec 2025 11:39:48 -0500 Subject: [PATCH 059/229] Alerting: Add tests for AlertRuleMenu component (#115473) * Alerting: Add tests for AlertRuleMenu component * Refactor test mocks according TESTING.md * Remove duplicate mock functions * Replace snapshot test with more readable assertion * Remove SETUP_ALERTING_DEV.md file * Refactor feature flags usage in tests --- .../rule-viewer/AlertRuleMenu.test.tsx | 1612 +++++++++++++++++ 1 file changed, 1612 insertions(+) create mode 100644 public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.test.tsx diff --git a/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.test.tsx b/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.test.tsx new file mode 100644 index 00000000000..e95b321c02c --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.test.tsx @@ -0,0 +1,1612 @@ +import { render, screen, testWithFeatureToggles, userEvent, waitFor } from 'test/test-utils'; +import { byLabelText, byRole } from 'testing-library-selector'; + +import { useAssistant } from '@grafana/assistant'; +import { GrafanaEdition } from '@grafana/data/internal'; +import { config, setPluginLinksHook } from '@grafana/runtime'; +import AlertRuleMenu from 'app/features/alerting/unified/components/rule-viewer/AlertRuleMenu'; +import { mockFolderApi, setupMswServer } from 'app/features/alerting/unified/mockApi'; +import { + getCloudRule, + getGrafanaRule, + grantUserPermissions, + mockDataSource, + mockFolder, + mockGrafanaRulerRule, + mockPromAlertingRule, + mockPromRecordingRule, + mockRulerGrafanaRecordingRule, +} from 'app/features/alerting/unified/mocks'; +import { setFolderAccessControl } from 'app/features/alerting/unified/mocks/server/configure'; +import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources'; +import * as miscUtils from 'app/features/alerting/unified/utils/misc'; +import { fromCombinedRule } from 'app/features/alerting/unified/utils/rule-id'; +import { AccessControlAction } from 'app/types/accessControl'; +import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; + +import { AlertRuleAction } from '../../hooks/useAbilities'; + +const mockOpenAssistant = jest.fn(); +jest.mock('@grafana/assistant', () => ({ + useAssistant: jest.fn(), + createAssistantContextItem: jest.fn((type: string, data: T) => ({ + type, + ...data, + })), +})); + +const mockUseAssistant = jest.mocked(useAssistant); + +const mockPauseExecute = jest.fn().mockResolvedValue(undefined); +jest.mock('../../hooks/ruleGroup/usePauseAlertRule', () => ({ + usePauseRuleInGroup: () => [ + { + execute: mockPauseExecute, + }, + { loading: false, error: undefined }, + ], +})); + +const server = setupMswServer(); + +setPluginLinksHook(() => ({ + links: [], + isLoading: false, +})); + +setupDataSources(); + +const user = userEvent.setup(); +const handleSilence = jest.fn(); +const handleDelete = jest.fn(); +const handleDuplicateRule = jest.fn(); + +const ui = { + moreButton: byLabelText(/More/), + menu: byRole('menu'), + menuItems: { + pause: byRole('menuitem', { name: /Pause evaluation/i }), + resume: byRole('menuitem', { name: /Resume evaluation/i }), + silence: byRole('menuitem', { name: /Silence notifications/i }), + duplicate: byRole('menuitem', { name: /Duplicate/i }), + copyLink: byRole('menuitem', { name: /Copy link/i }), + export: byRole('menuitem', { name: /Export/i }), + delete: byRole('menuitem', { name: /Delete/i }), + manageEnrichments: byRole('menuitem', { name: /Manage enrichments/i }), + declareIncident: byRole('link', { name: /Declare incident/i }), + analyzeRule: byRole('menuitem', { name: /Analyze rule/i }), + }, +}; + +const getMenuContents = async () => { + await screen.findByRole('menu'); + const allMenuItems = screen.queryAllByRole('menuitem').map((el) => el.textContent); + const allLinkItems = screen.queryAllByRole('link').map((el) => el.textContent); + + return [...allMenuItems, ...allLinkItems]; +}; + +const openMenu = async () => { + await user.click(await ui.moreButton.find()); + await waitFor(() => { + expect(ui.menu.query()).toBeInTheDocument(); + }); +}; + +// Helper function to create a default rule setup +const createDefaultRuleSetup = () => { + const mockRule = getGrafanaRule(); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + return { mockRule, identifier, groupIdentifier }; +}; + +describe('AlertRuleMenu', () => { + const originalBuildInfo = config.buildInfo; + + beforeEach(() => { + jest.clearAllMocks(); + mockPauseExecute.mockResolvedValue(undefined); + mockOpenAssistant.mockClear(); + // Default: assistant unavailable + mockUseAssistant.mockReturnValue({ + isAvailable: false, + openAssistant: mockOpenAssistant, + } as unknown as ReturnType); + + // Reset config to defaults + config.buildInfo = { ...originalBuildInfo }; + + // Set up default folder mock for Grafana rules (namespace-uid is the default folder UID) + mockFolderApi(server).folder('namespace-uid', mockFolder({ uid: 'namespace-uid', title: 'Test Folder' })); + + // Set up default permissions (no permissions granted by default) + grantUserPermissions([]); + setFolderAccessControl({}); + }); + + afterEach(() => { + config.buildInfo = originalBuildInfo; + }); + + describe('Basic Rendering', () => { + it('renders MoreButton correctly', async () => { + const mockRule = getGrafanaRule(); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + expect(await ui.moreButton.find()).toBeInTheDocument(); + }); + + it('opens menu when MoreButton is clicked', async () => { + const mockRule = getGrafanaRule(); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + + expect(ui.menu.query()).toBeInTheDocument(); + }); + + it('closes menu when clicking outside', async () => { + const mockRule = getGrafanaRule(); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menu.query()).toBeInTheDocument(); + + // Click outside the menu + await user.click(document.body); + + await waitFor(() => { + expect(ui.menu.query()).not.toBeInTheDocument(); + }); + }); + + it('menu contains expected structure', async () => { + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Inactive }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + + const menuContents = await getMenuContents(); + expect(menuContents).toHaveLength(1); + expect(menuContents).toContain('Copy link'); + }); + }); + + describe('Permissions', () => { + // Generalized test to reduce repetition for menu item visibility + type MenuItemTestCase = { + description: string; + action: AlertRuleAction; + menuItem: keyof typeof ui.menuItems; + granted: boolean; + shouldShow: boolean; + }; + + const testMenuItemVisibility = ({ description, action, menuItem, granted, shouldShow }: MenuItemTestCase) => { + it(description, async () => { + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + // Grant permissions based on action + const permissions: AccessControlAction[] = []; + const folderAccessControl: Record = {}; + + if (granted) { + switch (action) { + case AlertRuleAction.Pause: + case AlertRuleAction.Update: + permissions.push(AccessControlAction.AlertingRuleUpdate); + folderAccessControl[AccessControlAction.AlertingRuleUpdate] = true; + break; + case AlertRuleAction.Delete: + permissions.push(AccessControlAction.AlertingRuleDelete); + folderAccessControl[AccessControlAction.AlertingRuleDelete] = true; + break; + case AlertRuleAction.Duplicate: + permissions.push(AccessControlAction.AlertingRuleCreate); + break; + case AlertRuleAction.Silence: + permissions.push(AccessControlAction.AlertingInstanceCreate, AccessControlAction.AlertingSilenceCreate); + break; + case AlertRuleAction.ModifyExport: + permissions.push(AccessControlAction.AlertingRuleRead); + break; + } + } + + grantUserPermissions(permissions); + setFolderAccessControl(folderAccessControl); + + // Set up folder mock for Grafana rules with matching access control + mockFolderApi(server).folder( + 'namespace-uid', + mockFolder({ + uid: 'namespace-uid', + title: 'Test Folder', + accessControl: folderAccessControl, + }) + ); + + render( + + ); + + await openMenu(); + if (shouldShow) { + expect(await ui.menuItems[menuItem].find()).toBeInTheDocument(); + } else { + expect(ui.menuItems[menuItem].query()).not.toBeInTheDocument(); + } + }); + }; + + describe('Pause/Resume visibility', () => { + testMenuItemVisibility({ + description: 'shows Pause when pause permission is granted', + action: AlertRuleAction.Pause, + menuItem: 'pause', + granted: true, + shouldShow: true, + }); + + testMenuItemVisibility({ + description: 'hides Pause when pause permission is denied', + action: AlertRuleAction.Pause, + menuItem: 'pause', + granted: false, + shouldShow: false, + }); + }); + + describe('Delete visibility', () => { + testMenuItemVisibility({ + description: 'shows Delete when delete permission is granted', + action: AlertRuleAction.Delete, + menuItem: 'delete', + granted: true, + shouldShow: true, + }); + + testMenuItemVisibility({ + description: 'hides Delete when delete permission is denied', + action: AlertRuleAction.Delete, + menuItem: 'delete', + granted: false, + shouldShow: false, + }); + }); + + describe('Duplicate visibility', () => { + testMenuItemVisibility({ + description: 'shows Duplicate when duplicate permission is granted', + action: AlertRuleAction.Duplicate, + menuItem: 'duplicate', + granted: true, + shouldShow: true, + }); + + testMenuItemVisibility({ + description: 'hides Duplicate when duplicate permission is denied', + action: AlertRuleAction.Duplicate, + menuItem: 'duplicate', + granted: false, + shouldShow: false, + }); + }); + + describe('Silence visibility', () => { + testMenuItemVisibility({ + description: 'shows Silence when silence permission is granted', + action: AlertRuleAction.Silence, + menuItem: 'silence', + granted: true, + shouldShow: true, + }); + + testMenuItemVisibility({ + description: 'hides Silence when silence permission is denied', + action: AlertRuleAction.Silence, + menuItem: 'silence', + granted: false, + shouldShow: false, + }); + }); + + describe('Export visibility', () => { + testMenuItemVisibility({ + description: 'shows Export when export permission is granted', + action: AlertRuleAction.ModifyExport, + menuItem: 'export', + granted: true, + shouldShow: true, + }); + + testMenuItemVisibility({ + description: 'hides Export when export permission is denied', + action: AlertRuleAction.ModifyExport, + menuItem: 'export', + granted: false, + shouldShow: false, + }); + }); + + describe('Copy Link visibility', () => { + it('shows Copy Link when shareUrl exists', async () => { + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + expect(await ui.menuItems.copyLink.find()).toBeInTheDocument(); + }); + }); + }); + + describe('Rule Types', () => { + beforeEach(() => { + // Grant all permissions for testing rule type differences + grantUserPermissions([ + AccessControlAction.AlertingRuleRead, + AccessControlAction.AlertingRuleUpdate, + AccessControlAction.AlertingRuleDelete, + AccessControlAction.AlertingRuleCreate, + AccessControlAction.AlertingInstanceCreate, + AccessControlAction.AlertingSilenceCreate, + ]); + setFolderAccessControl({ + [AccessControlAction.AlertingRuleUpdate]: true, + [AccessControlAction.AlertingRuleDelete]: true, + }); + mockFolderApi(server).folder( + 'namespace-uid', + mockFolder({ + uid: 'namespace-uid', + title: 'Test Folder', + accessControl: { + [AccessControlAction.AlertingRuleUpdate]: true, + [AccessControlAction.AlertingRuleDelete]: true, + }, + }) + ); + }); + + describe('Grafana-managed rules', () => { + it('shows Pause option for Grafana-managed alerting rules', async () => { + const mockRule = getGrafanaRule(); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(await ui.menuItems.pause.find()).toBeInTheDocument(); + }); + + it('does not show Pause option for datasource-managed rules', async () => { + const datasource = mockDataSource({ uid: 'mimir', name: 'Mimir' }); + const mockRule = getCloudRule({}, { rulesSource: datasource }); + const identifier = fromCombinedRule(datasource.name, mockRule); + const groupIdentifier = { + groupOrigin: 'datasource' as const, + rulesSource: { uid: datasource.uid, name: datasource.name, ruleSourceType: 'datasource' as const }, + namespace: { name: 'namespace-name' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menuItems.pause.query()).not.toBeInTheDocument(); + }); + }); + + describe('Alerting vs Recording rules', () => { + it('shows Silence option for alerting rules', async () => { + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ type: PromRuleType.Alerting }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(await ui.menuItems.silence.find()).toBeInTheDocument(); + }); + + it('does not show Silence option for recording rules', async () => { + const mockRule = getGrafanaRule({ + promRule: mockPromRecordingRule({ type: PromRuleType.Recording }), + }); + // Override the rulerRule to be a recording rule + mockRule.rulerRule = mockRulerGrafanaRecordingRule( + {}, + { + uid: 'mock-rule-uid-123', + namespace_uid: 'namespace-uid', + } + ); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + mockFolderApi(server).folder( + 'namespace-uid', + mockFolder({ + uid: 'namespace-uid', + title: 'Test Folder', + }) + ); + + render( + + ); + + await openMenu(); + expect(ui.menuItems.silence.query()).not.toBeInTheDocument(); + }); + }); + + describe('Provisioned rules', () => { + it('hides Delete option for provisioned rules', async () => { + const mockRule = getGrafanaRule({ + rulerRule: mockGrafanaRulerRule({ provenance: 'file' }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menuItems.delete.query()).not.toBeInTheDocument(); + }); + + it('hides Pause option for provisioned rules', async () => { + const mockRule = getGrafanaRule({ + rulerRule: mockGrafanaRulerRule({ provenance: 'file' }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menuItems.pause.query()).not.toBeInTheDocument(); + }); + }); + + describe('Mixed data scenarios', () => { + it('works with only promRule available', async () => { + const mockRule = getGrafanaRule(); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menu.query()).toBeInTheDocument(); + }); + + it('works with only rulerRule available', async () => { + const mockRule = getGrafanaRule(); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menu.query()).toBeInTheDocument(); + }); + + it('works with both promRule and rulerRule available', async () => { + const mockRule = getGrafanaRule(); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menu.query()).toBeInTheDocument(); + }); + }); + }); + + describe('Handler Callbacks', () => { + describe('handleSilence', () => { + it('calls handleSilence when Silence menu item is clicked', async () => { + grantUserPermissions([AccessControlAction.AlertingInstanceCreate, AccessControlAction.AlertingSilenceCreate]); + mockFolderApi(server).folder('namespace-uid', mockFolder({ uid: 'namespace-uid', title: 'Test Folder' })); + + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + await user.click(await ui.menuItems.silence.find()); + + expect(handleSilence).toHaveBeenCalledTimes(1); + }); + }); + + describe('handleManageEnrichments', () => { + testWithFeatureToggles({ + enable: ['alertEnrichment', 'alertingEnrichmentPerRule'], + }); + + it('calls handleManageEnrichments when Manage enrichments menu item is clicked', async () => { + // Both toggles need to be enabled: alertEnrichment for the hook, alertingEnrichmentPerRule for the component + grantUserPermissions([AccessControlAction.AlertingEnrichmentsRead]); + mockFolderApi(server).folder('namespace-uid', mockFolder({ uid: 'namespace-uid', title: 'Test Folder' })); + + const handleManageEnrichments = jest.fn(); + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + await user.click(await ui.menuItems.manageEnrichments.find()); + + expect(handleManageEnrichments).toHaveBeenCalledTimes(1); + }); + }); + + describe('handleDelete', () => { + it('calls handleDelete with correct identifier and groupIdentifier when Delete menu item is clicked', async () => { + grantUserPermissions([AccessControlAction.AlertingRuleDelete]); + setFolderAccessControl({ [AccessControlAction.AlertingRuleDelete]: true }); + mockFolderApi(server).folder( + 'namespace-uid', + mockFolder({ + uid: 'namespace-uid', + title: 'Test Folder', + accessControl: { [AccessControlAction.AlertingRuleDelete]: true }, + }) + ); + + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + await user.click(await ui.menuItems.delete.find()); + + expect(handleDelete).toHaveBeenCalledTimes(1); + expect(handleDelete).toHaveBeenCalledWith(identifier, groupIdentifier); + }); + + it('does not call handleDelete when identifier is not editable', async () => { + grantUserPermissions([AccessControlAction.AlertingRuleDelete]); + setFolderAccessControl({ [AccessControlAction.AlertingRuleDelete]: true }); + mockFolderApi(server).folder( + 'namespace-uid', + mockFolder({ + uid: 'namespace-uid', + title: 'Test Folder', + accessControl: { [AccessControlAction.AlertingRuleDelete]: true }, + }) + ); + + const { mockRule, groupIdentifier } = createDefaultRuleSetup(); + // Create a non-editable identifier (e.g., Prometheus rule identifier without rulerRule) + // For external rules without rulerRule, the delete button should not appear + const identifier = fromCombinedRule('prometheus', { + ...mockRule, + rulerRule: undefined, + }); + + render( + + ); + + await openMenu(); + expect(ui.menuItems.delete.query()).not.toBeInTheDocument(); + expect(handleDelete).not.toHaveBeenCalled(); + }); + }); + + describe('handleDuplicateRule', () => { + it('calls handleDuplicateRule with correct identifier when Duplicate menu item is clicked', async () => { + grantUserPermissions([AccessControlAction.AlertingRuleCreate]); + mockFolderApi(server).folder('namespace-uid', mockFolder({ uid: 'namespace-uid', title: 'Test Folder' })); + + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + await user.click(await ui.menuItems.duplicate.find()); + + expect(handleDuplicateRule).toHaveBeenCalledTimes(1); + expect(handleDuplicateRule).toHaveBeenCalledWith(identifier); + }); + }); + + describe('onPauseChange', () => { + it('calls onPauseChange after pause state change when Pause menu item is clicked', async () => { + const onPauseChange = jest.fn(); + grantUserPermissions([AccessControlAction.AlertingRuleUpdate]); + setFolderAccessControl({ [AccessControlAction.AlertingRuleUpdate]: true }); + mockFolderApi(server).folder( + 'namespace-uid', + mockFolder({ + uid: 'namespace-uid', + title: 'Test Folder', + accessControl: { [AccessControlAction.AlertingRuleUpdate]: true }, + }) + ); + + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + await user.click(await ui.menuItems.pause.find()); + + await waitFor(() => { + expect(mockPauseExecute).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(onPauseChange).toHaveBeenCalledTimes(1); + }); + expect(onPauseChange).toHaveBeenCalledWith(); + }); + + it('calls onPauseChange after resume state change when Resume menu item is clicked', async () => { + const onPauseChange = jest.fn(); + grantUserPermissions([AccessControlAction.AlertingRuleUpdate]); + setFolderAccessControl({ [AccessControlAction.AlertingRuleUpdate]: true }); + mockFolderApi(server).folder( + 'namespace-uid', + mockFolder({ + uid: 'namespace-uid', + title: 'Test Folder', + accessControl: { [AccessControlAction.AlertingRuleUpdate]: true }, + }) + ); + + const mockRule = getGrafanaRule({ + rulerRule: mockGrafanaRulerRule({ is_paused: true }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + await user.click(await ui.menuItems.resume.find()); + + await waitFor(() => { + expect(mockPauseExecute).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(onPauseChange).toHaveBeenCalledTimes(1); + }); + expect(onPauseChange).toHaveBeenCalledWith(); + }); + }); + }); + + describe('Feature Flags', () => { + describe('alertingEnrichmentPerRule', () => { + describe('when feature flag is disabled', () => { + testWithFeatureToggles({ + disable: ['alertingEnrichmentPerRule'], + }); + + it('hides Manage enrichments when feature flag is disabled', async () => { + grantUserPermissions([AccessControlAction.AlertingEnrichmentsRead]); + + const handleManageEnrichments = jest.fn(); + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + expect(ui.menuItems.manageEnrichments.query()).not.toBeInTheDocument(); + }); + }); + + describe('when feature flag is enabled', () => { + testWithFeatureToggles({ + enable: ['alertEnrichment', 'alertingEnrichmentPerRule'], + }); + + it('shows Manage enrichments when feature flag is enabled and all conditions are met', async () => { + // Both toggles need to be enabled: alertEnrichment for the hook, alertingEnrichmentPerRule for the component + grantUserPermissions([AccessControlAction.AlertingEnrichmentsRead]); + mockFolderApi(server).folder('namespace-uid', mockFolder({ uid: 'namespace-uid', title: 'Test Folder' })); + + const handleManageEnrichments = jest.fn(); + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + expect(await ui.menuItems.manageEnrichments.find()).toBeInTheDocument(); + }); + }); + + describe('when feature flags are enabled but permission is missing', () => { + testWithFeatureToggles({ + enable: ['alertEnrichment', 'alertingEnrichmentPerRule'], + }); + + it('hides Manage enrichments when enrichment ability is not allowed', async () => { + // Enable both toggles to ensure we're testing the permission check, not the toggles + grantUserPermissions([]); + mockFolderApi(server).folder('namespace-uid', mockFolder({ uid: 'namespace-uid', title: 'Test Folder' })); + + const handleManageEnrichments = jest.fn(); + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + expect(ui.menuItems.manageEnrichments.query()).not.toBeInTheDocument(); + }); + }); + }); + + describe('Open-source vs Enterprise', () => { + it('hides Declare Incident in open-source edition for firing alerting rules', async () => { + config.buildInfo.edition = GrafanaEdition.OpenSource; + config.buildInfo.env = 'production'; + + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Firing }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menuItems.declareIncident.query()).not.toBeInTheDocument(); + }); + + it('shows Declare Incident in enterprise edition for firing alerting rules', async () => { + config.buildInfo.edition = GrafanaEdition.Enterprise; + config.buildInfo.env = 'production'; + + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Firing }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(await ui.menuItems.declareIncident.find()).toBeInTheDocument(); + }); + + it('shows Declare Incident in dev mode even for open-source edition', async () => { + config.buildInfo.edition = GrafanaEdition.OpenSource; + config.buildInfo.env = 'development'; + + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Firing }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(await ui.menuItems.declareIncident.find()).toBeInTheDocument(); + }); + + it('hides Declare Incident for non-firing alerting rules in enterprise', async () => { + config.buildInfo.edition = GrafanaEdition.Enterprise; + config.buildInfo.env = 'production'; + + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Inactive }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menuItems.declareIncident.query()).not.toBeInTheDocument(); + }); + + it('hides Declare Incident for recording rules', async () => { + config.buildInfo.edition = GrafanaEdition.Enterprise; + config.buildInfo.env = 'production'; + + const mockRule = getGrafanaRule({ + promRule: mockPromRecordingRule({ type: PromRuleType.Recording }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menuItems.declareIncident.query()).not.toBeInTheDocument(); + }); + }); + + describe('Assistant availability', () => { + beforeEach(() => { + // Reset config to ensure clean state for assistant tests + config.buildInfo = { ...originalBuildInfo }; + config.buildInfo.env = 'production'; + config.buildInfo.edition = GrafanaEdition.OpenSource; + // Reset assistant mock to default (unavailable) for each test + mockUseAssistant.mockReturnValue({ + isAvailable: false, + openAssistant: mockOpenAssistant, + } as unknown as ReturnType); + }); + + it('shows Analyze Rule when assistant is available for Grafana-managed rules', async () => { + // Override mock to return available + mockUseAssistant.mockReturnValue({ + isAvailable: true, + openAssistant: mockOpenAssistant, + } as unknown as ReturnType); + + // Create a Grafana rule with promRule that has uid and folderUid + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ + uid: 'test-rule-uid', + folderUid: 'test-folder-uid', + }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(await ui.menuItems.analyzeRule.find()).toBeInTheDocument(); + }); + + it('hides Analyze Rule when assistant is unavailable', async () => { + // Mock already set to unavailable in beforeEach, but be explicit + mockUseAssistant.mockReturnValue({ + isAvailable: false, + openAssistant: mockOpenAssistant, + } as unknown as ReturnType); + + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + expect(ui.menuItems.analyzeRule.query()).not.toBeInTheDocument(); + }); + + it('hides Analyze Rule for datasource-managed rules even when assistant is available', async () => { + mockUseAssistant.mockReturnValue({ + isAvailable: true, + openAssistant: mockOpenAssistant, + } as unknown as ReturnType); + + const datasource = mockDataSource({ uid: 'mimir', name: 'Mimir' }); + const mockRule = getCloudRule({}, { rulesSource: datasource }); + const identifier = fromCombinedRule(datasource.name, mockRule); + const groupIdentifier = { + groupOrigin: 'datasource' as const, + rulesSource: { uid: datasource.uid, name: datasource.name, ruleSourceType: 'datasource' as const }, + namespace: { name: 'namespace-name' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menuItems.analyzeRule.query()).not.toBeInTheDocument(); + }); + }); + }); + + describe('Edge Cases', () => { + describe('Missing Data', () => { + describe('with enrichment feature flags enabled', () => { + testWithFeatureToggles({ + enable: ['alertEnrichment', 'alertingEnrichmentPerRule'], + }); + + it('hides Manage enrichments when ruleUid is missing even if all other conditions are met', async () => { + grantUserPermissions([AccessControlAction.AlertingEnrichmentsRead]); + // Note: Cloud rules typically don't have a ruleUid, so this tests that case + mockFolderApi(server).folder('namespace-uid', mockFolder({ uid: 'namespace-uid', title: 'Test Folder' })); + + const handleManageEnrichments = jest.fn(); + const datasource = mockDataSource({ uid: 'prometheus', name: 'Prometheus' }); + const mockRule = getCloudRule({}, { rulesSource: datasource }); + const identifier = fromCombinedRule(datasource.name, mockRule); + const groupIdentifier = { + groupOrigin: 'datasource' as const, + rulesSource: { uid: datasource.uid, name: datasource.name, ruleSourceType: 'datasource' as const }, + namespace: { name: 'namespace-name' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menuItems.manageEnrichments.query()).not.toBeInTheDocument(); + }); + }); + + it('hides Pause option when ruleUid is missing even if pause permission is granted', async () => { + grantUserPermissions([AccessControlAction.AlertingRuleUpdate]); + setFolderAccessControl({ [AccessControlAction.AlertingRuleUpdate]: true }); + + const datasource = mockDataSource({ uid: 'prometheus', name: 'Prometheus' }); + const mockRule = getCloudRule({}, { rulesSource: datasource }); + const identifier = fromCombinedRule(datasource.name, mockRule); + const groupIdentifier = { + groupOrigin: 'datasource' as const, + rulesSource: { uid: datasource.uid, name: datasource.name, ruleSourceType: 'datasource' as const }, + namespace: { name: 'namespace-name' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menuItems.pause.query()).not.toBeInTheDocument(); + }); + + it('pause still works when onPauseChange is not provided', async () => { + grantUserPermissions([AccessControlAction.AlertingRuleUpdate]); + setFolderAccessControl({ [AccessControlAction.AlertingRuleUpdate]: true }); + mockFolderApi(server).folder( + 'namespace-uid', + mockFolder({ + uid: 'namespace-uid', + title: 'Test Folder', + accessControl: { [AccessControlAction.AlertingRuleUpdate]: true }, + }) + ); + + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + await user.click(await ui.menuItems.pause.find()); + + // Pause should still execute even without callback + await waitFor(() => { + expect(mockPauseExecute).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('Empty States', () => { + it('shows minimal menu with only Copy Link when no permissions are granted', async () => { + // Use a non-firing rule to avoid Declare Incident showing + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Inactive }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + config.buildInfo.edition = GrafanaEdition.OpenSource; + config.buildInfo.env = 'production'; + + render( + + ); + + await openMenu(); + + expect(await ui.menuItems.copyLink.find()).toBeInTheDocument(); + expect(ui.menuItems.pause.query()).not.toBeInTheDocument(); + expect(ui.menuItems.silence.query()).not.toBeInTheDocument(); + expect(ui.menuItems.duplicate.query()).not.toBeInTheDocument(); + expect(ui.menuItems.export.query()).not.toBeInTheDocument(); + expect(ui.menuItems.delete.query()).not.toBeInTheDocument(); + expect(ui.menuItems.manageEnrichments.query()).not.toBeInTheDocument(); + expect(ui.menuItems.declareIncident.query()).not.toBeInTheDocument(); + expect(ui.menuItems.analyzeRule.query()).not.toBeInTheDocument(); + }); + + it('menu still opens even when no applicable items are available', async () => { + // All abilities denied and no shareUrl + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Inactive }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + config.buildInfo.edition = GrafanaEdition.OpenSource; + config.buildInfo.env = 'production'; + + // Mock createShareLink to return undefined + const createShareLinkSpy = jest.spyOn(miscUtils, 'createShareLink'); + createShareLinkSpy.mockReturnValue(undefined); + + render( + + ); + + await openMenu(); + + expect(ui.menu.query()).toBeInTheDocument(); + const menuItems = screen.queryAllByRole('menuitem'); + const linkItems = screen.queryAllByRole('link'); + expect(menuItems.length).toBe(0); + expect(linkItems.length).toBe(0); + + createShareLinkSpy.mockRestore(); + }); + }); + + describe('Error Handling', () => { + it('handles gracefully when clipboard API is unavailable', async () => { + const originalClipboard = navigator.clipboard; + // Mock clipboard as undefined to simulate unavailable API + Object.defineProperty(navigator, 'clipboard', { + value: undefined, + writable: true, + configurable: true, + }); + + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Inactive }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + config.buildInfo.edition = GrafanaEdition.OpenSource; + config.buildInfo.env = 'production'; + + render( + + ); + + await openMenu(); + const copyLinkItem = await ui.menuItems.copyLink.find(); + + await expect(user.click(copyLinkItem)).resolves.not.toThrow(); + + // Restore clipboard + Object.defineProperty(navigator, 'clipboard', { + value: originalClipboard, + writable: true, + configurable: true, + }); + }); + + it('handles gracefully when shareUrl is undefined', async () => { + // Use a non-firing rule to avoid Declare Incident showing + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Inactive }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + config.buildInfo.edition = GrafanaEdition.OpenSource; + config.buildInfo.env = 'production'; + + // Mock createShareLink to return undefined + const createShareLinkSpy = jest.spyOn(require('app/features/alerting/unified/utils/misc'), 'createShareLink'); + createShareLinkSpy.mockReturnValue(undefined); + + render( + + ); + + await openMenu(); + + expect(ui.menuItems.copyLink.query()).not.toBeInTheDocument(); + expect(ui.menu.query()).toBeInTheDocument(); + + createShareLinkSpy.mockRestore(); + }); + }); + }); +}); From 49032ae3d7a55a62563fcfcc6d45bb69b82b2159 Mon Sep 17 00:00:00 2001 From: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Fri, 19 Dec 2025 11:23:27 -0600 Subject: [PATCH 060/229] VizSuggestions: Update selected suggestion styling (#115581) * update selected suggestion style * update highlight styles for light theme, add inert to div * remove commented-out original idea --------- Co-authored-by: Paul Marbach --- .../VisualizationSuggestionCard.tsx | 39 +++++++++---------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx index 7248dbcc1e5..632c143fe3a 100644 --- a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx +++ b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx @@ -2,7 +2,7 @@ import { css, cx } from '@emotion/css'; import { cloneDeep } from 'lodash'; import { CSSProperties, HTMLAttributes, ReactNode } from 'react'; -import { colorManipulator, GrafanaTheme2, PanelData, PanelPluginVisualizationSuggestion } from '@grafana/data'; +import { GrafanaTheme2, PanelData, PanelPluginVisualizationSuggestion } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { config } from '@grafana/runtime'; import { Tooltip, useStyles2 } from '@grafana/ui'; @@ -31,7 +31,11 @@ export function VisualizationSuggestionCard({ const commonButtonProps = { 'aria-label': suggestion.name, - className: cx(className, styles.vizBox), + className: cx( + className, + styles.vizBox, + config.featureToggles.newVizSuggestions && isSelected && styles.selectedBox + ), 'data-testid': selectors.components.VisualizationPreview.card(suggestion.name), style: outerStyles, tabIndex: -1, // selection is handled by parent container @@ -56,7 +60,12 @@ export function VisualizationSuggestionCard({ content = ( ); @@ -82,22 +89,8 @@ export function VisualizationSuggestionCard({ const getStyles = (theme: GrafanaTheme2) => { return { - hoverPane: css({ - position: 'absolute', - top: -4, - left: -4, - right: -2, - bottom: -2, - borderRadius: theme.spacing(0.5), - background: 'transparent', - [theme.transitions.handleMotion('no-preference', 'reduce')]: { - transition: theme.transitions.create(['background'], { - duration: theme.transitions.duration.short, - }), - }, - }), - hoverPaneSelected: css({ - background: colorManipulator.alpha(theme.colors.text.primary, 0.1), + selectedSuggestion: css({ + filter: `blur(1px) ${theme.isDark ? 'brightness(0.5)' : 'opacity(0.3)'}`, }), vizBox: css({ position: 'relative', @@ -149,6 +142,10 @@ const getStyles = (theme: GrafanaTheme2) => { top: '6px', left: '6px', }), + selectedBox: css({ + border: `1px solid ${theme.colors.primary.border}`, + background: theme.colors.action.selected, + }), }; }; From f91efcfe2c8f6e6078d64118e3fd1332f9c12873 Mon Sep 17 00:00:00 2001 From: Jesse David Peterson Date: Fri, 19 Dec 2025 13:12:01 -0500 Subject: [PATCH 061/229] TimeSeries: Fix truncated label text in legend table mode (#115647) * fix(legend-table): remove arbitrary 600px max width for full width cells * test(legend-table): backfill test coverage for viz legend table * test(legend-table): backfill test coverage for viz legend table item * refactor(legend-table): use derived theme spacing, not hard-coded values --- .../VizLegend/VizLegendTable.test.tsx | 78 ++++++++++++ .../components/VizLegend/VizLegendTable.tsx | 1 - .../VizLegend/VizLegendTableItem.test.tsx | 112 ++++++++++++++++++ .../VizLegend/VizLegendTableItem.tsx | 64 ++++++---- 4 files changed, 232 insertions(+), 23 deletions(-) create mode 100644 packages/grafana-ui/src/components/VizLegend/VizLegendTable.test.tsx create mode 100644 packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.test.tsx diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.test.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.test.tsx new file mode 100644 index 00000000000..131133bcdfb --- /dev/null +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from '@testing-library/react'; + +import { VizLegendTable } from './VizLegendTable'; +import { VizLegendItem } from './types'; + +describe('VizLegendTable', () => { + const mockItems: VizLegendItem[] = [ + { label: 'Series 1', color: 'red', yAxis: 1 }, + { label: 'Series 2', color: 'blue', yAxis: 1 }, + { label: 'Series 3', color: 'green', yAxis: 1 }, + ]; + + it('renders without crashing', () => { + const { container } = render(); + expect(container.querySelector('table')).toBeInTheDocument(); + }); + + it('renders all items', () => { + render(); + expect(screen.getByText('Series 1')).toBeInTheDocument(); + expect(screen.getByText('Series 2')).toBeInTheDocument(); + expect(screen.getByText('Series 3')).toBeInTheDocument(); + }); + + it('renders table headers when items have display values', () => { + const itemsWithStats: VizLegendItem[] = [ + { + label: 'Series 1', + color: 'red', + yAxis: 1, + getDisplayValues: () => [ + { numeric: 100, text: '100', title: 'Max' }, + { numeric: 50, text: '50', title: 'Min' }, + ], + }, + ]; + render(); + expect(screen.getByText('Max')).toBeInTheDocument(); + expect(screen.getByText('Min')).toBeInTheDocument(); + }); + + it('renders sort icon when sorted', () => { + const { container } = render( + + ); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + + it('calls onToggleSort when header is clicked', () => { + const onToggleSort = jest.fn(); + render(); + const header = screen.getByText('Name'); + header.click(); + expect(onToggleSort).toHaveBeenCalledWith('Name'); + }); + + it('does not call onToggleSort when not sortable', () => { + const onToggleSort = jest.fn(); + render(); + const header = screen.getByText('Name'); + header.click(); + expect(onToggleSort).not.toHaveBeenCalled(); + }); + + it('renders with long labels', () => { + const itemsWithLongLabels: VizLegendItem[] = [ + { + label: 'This is a very long series name that should be scrollable within its table cell', + color: 'red', + yAxis: 1, + }, + ]; + render(); + expect( + screen.getByText('This is a very long series name that should be scrollable within its table cell') + ).toBeInTheDocument(); + }); +}); diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx index b654a2d3ac6..0c2859453eb 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx @@ -119,7 +119,6 @@ const getStyles = (theme: GrafanaTheme2) => ({ table: css({ width: '100%', 'th:first-child': { - width: '100%', borderBottom: `1px solid ${theme.colors.border.weak}`, }, }), diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.test.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.test.tsx new file mode 100644 index 00000000000..4ca95aa395c --- /dev/null +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.test.tsx @@ -0,0 +1,112 @@ +import { render, screen } from '@testing-library/react'; + +import { LegendTableItem } from './VizLegendTableItem'; +import { VizLegendItem } from './types'; + +describe('LegendTableItem', () => { + const mockItem: VizLegendItem = { + label: 'Series 1', + color: 'red', + yAxis: 1, + }; + + it('renders without crashing', () => { + const { container } = render( +
+ + + +
+ ); + expect(container.querySelector('tr')).toBeInTheDocument(); + }); + + it('renders label text', () => { + render( + + + + +
+ ); + expect(screen.getByText('Series 1')).toBeInTheDocument(); + }); + + it('renders with long label text', () => { + const longLabelItem: VizLegendItem = { + ...mockItem, + label: 'This is a very long series name that should be scrollable in the table cell', + }; + render( + + + + +
+ ); + expect( + screen.getByText('This is a very long series name that should be scrollable in the table cell') + ).toBeInTheDocument(); + }); + + it('renders stat values when provided', () => { + const itemWithStats: VizLegendItem = { + ...mockItem, + getDisplayValues: () => [ + { numeric: 100, text: '100', title: 'Max' }, + { numeric: 50, text: '50', title: 'Min' }, + ], + }; + render( + + + + +
+ ); + expect(screen.getByText('100')).toBeInTheDocument(); + expect(screen.getByText('50')).toBeInTheDocument(); + }); + + it('renders right y-axis indicator when yAxis is 2', () => { + const rightAxisItem: VizLegendItem = { + ...mockItem, + yAxis: 2, + }; + render( + + + + +
+ ); + expect(screen.getByText('(right y-axis)')).toBeInTheDocument(); + }); + + it('calls onLabelClick when label is clicked', () => { + const onLabelClick = jest.fn(); + render( + + + + +
+ ); + const button = screen.getByRole('button'); + button.click(); + expect(onLabelClick).toHaveBeenCalledWith(mockItem, expect.any(Object)); + }); + + it('does not call onClick when readonly', () => { + const onLabelClick = jest.fn(); + render( + + + + +
+ ); + const button = screen.getByRole('button'); + expect(button).toBeDisabled(); + }); +}); diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx index 335cf4309e9..56ec6cb733e 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx @@ -69,7 +69,7 @@ export const LegendTableItem = ({ return ( - + - +
+ +
{item.getDisplayValues && @@ -128,6 +130,27 @@ const getStyles = (theme: GrafanaTheme2) => { background: rowHoverBg, }, }), + labelCell: css({ + label: 'LegendLabelCell', + maxWidth: 0, + width: '100%', + }), + labelCellInner: css({ + label: 'LegendLabelCellInner', + display: 'block', + flex: 1, + minWidth: 0, + overflowX: 'auto', + overflowY: 'hidden', + paddingRight: theme.spacing(3), + scrollbarWidth: 'none', + msOverflowStyle: 'none', + maskImage: `linear-gradient(to right, black calc(100% - ${theme.spacing(3)}), transparent 100%)`, + WebkitMaskImage: `linear-gradient(to right, black calc(100% - ${theme.spacing(3)}), transparent 100%)`, + '&::-webkit-scrollbar': { + display: 'none', + }, + }), label: css({ label: 'LegendLabel', whiteSpace: 'nowrap', @@ -135,9 +158,6 @@ const getStyles = (theme: GrafanaTheme2) => { border: 'none', fontSize: 'inherit', padding: 0, - maxWidth: '600px', - textOverflow: 'ellipsis', - overflow: 'hidden', userSelect: 'text', }), labelDisabled: css({ From 471d6f5236b6521b09a0c5d95c7e6f33764a4865 Mon Sep 17 00:00:00 2001 From: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:27:39 -0500 Subject: [PATCH 062/229] Docs: Add suggested dashboards (#114729) --- docs/sources/datasources/_index.md | 6 +++++ .../create-dashboard/index.md | 24 ++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/sources/datasources/_index.md b/docs/sources/datasources/_index.md index e7da61ea2f5..28d46c9c600 100644 --- a/docs/sources/datasources/_index.md +++ b/docs/sources/datasources/_index.md @@ -112,6 +112,12 @@ For example, this video demonstrates the visual Prometheus query builder: For general information about querying in Grafana, and common options and user interface elements across all query editors, refer to [Query and transform data](ref:query-transform-data). +## Build a dashboard from the data source + +After you've configured a data source, you can start creating a dashboard directly from it, by clicking the **Build a dashboard** button. + +For more information, refer to [Begin dashboard creation from data source configuration](https://grafana.com/docs/grafana//visualizations/dashboards/build-dashboards/create-dashboard/#begin-dashboard-creation-from-connections). + ## Special data sources Grafana includes three special data sources: diff --git a/docs/sources/visualizations/dashboards/build-dashboards/create-dashboard/index.md b/docs/sources/visualizations/dashboards/build-dashboards/create-dashboard/index.md index 3d5e765e0cd..10196a40811 100644 --- a/docs/sources/visualizations/dashboards/build-dashboards/create-dashboard/index.md +++ b/docs/sources/visualizations/dashboards/build-dashboards/create-dashboard/index.md @@ -99,7 +99,7 @@ Dashboards and panels allow you to show your data in visual form. Each panel nee - Understand the query language of the target data source. - Ensure that data source for which you are writing a query has been added. For more information about adding a data source, refer to [Add a data source](ref:add-a-data-source) if you need instructions. -**To create a dashboard**: +To create a dashboard, follow these steps: {{< shared id="create-dashboard" >}} @@ -171,6 +171,28 @@ Dashboards and panels allow you to show your data in visual form. Each panel nee Now, when you want to make more changes to the saved dashboard, click **Edit** in the top-right corner. +### Begin dashboard creation from data source configuration + +You can start the process of creating a dashboard directly from a data source rather than from the **Dashboards** page. + +To begin building a dashboard directly from a data source, follow these steps: + +1. Navigate to **Connections > Data sources**. +1. On the row of the data source for which you want to build a dashboard, click **Build a dashboard**. + + The empty dashboard page opens. + +1. Do one of the following: + - Click **+Add visualization** to configure all the elements of the new dashboard. + - Select one of the suggested dashboards by clicking its **Use dashboard** button. This can be helpful when you're not sure how to most effectively visualize your data. + The suggested dashboards are specific to your data source type (for example, Prometheus, Loki, or Elasticsearch). If there are more than three dashboard suggestions, you can click **View all** to see the rest of them. + + ![Empty dashboard with add visualization and suggested dashboard options](/media/docs/grafana/dashboards/screenshot-suggested-dashboards-v12.3.png) + + {{< docs/public-preview product="Suggested dashboards" >}} + +1. Complete the rest of the dashboard configuration. For more detailed steps, refer to [Create a dashboard](#create-a-dashboard), beginning at step five. + ## Copy a dashboard To copy a dashboard, follow these steps: From 14c595f2066151ad5b9a4f1d852f60eac63a8b29 Mon Sep 17 00:00:00 2001 From: Liza Detrick <114438185+L2D2Grafana@users.noreply.github.com> Date: Fri, 19 Dec 2025 10:52:02 -0800 Subject: [PATCH 063/229] Logs: Cell format value on inspect should use Code view for arrays, objects, and JSON strings (#115037) --- .../src/components/Table/CellActions.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/grafana-ui/src/components/Table/CellActions.tsx b/packages/grafana-ui/src/components/Table/CellActions.tsx index e2fe8a658ba..f21be659c4f 100644 --- a/packages/grafana-ui/src/components/Table/CellActions.tsx +++ b/packages/grafana-ui/src/components/Table/CellActions.tsx @@ -1,3 +1,4 @@ +import { isPlainObject } from 'lodash'; import { useCallback } from 'react'; import * as React from 'react'; @@ -63,7 +64,18 @@ export function CellActions({ tooltip={t('grafana-ui.table.cell-inspect', 'Inspect value')} onClick={() => { if (setInspectCell) { - setInspectCell({ value: cell.value, mode: previewMode }); + let mode = TableCellInspectorMode.text; + let inspectValue = cell.value; + try { + const parsed = typeof inspectValue === 'string' ? JSON.parse(inspectValue) : inspectValue; + if (Array.isArray(parsed) || isPlainObject(parsed)) { + inspectValue = JSON.stringify(parsed, null, 2); + mode = TableCellInspectorMode.code; + } + } catch { + // do nothing + } + setInspectCell({ value: inspectValue, mode }); } }} {...commonButtonProps} From 4164239f561e70669549ef376bea20b39c638f6e Mon Sep 17 00:00:00 2001 From: Will Assis <35489495+gassiss@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:27:06 -0500 Subject: [PATCH 064/229] unified-storage: implement sqlkv Save method (#115458) * unified-storage: sqlkv save method --- .../data/sqlkv_delete_legacy_resource.sql | 5 + .../resource/data/sqlkv_insert_datastore.sql | 21 ++ .../data/sqlkv_insert_legacy_resource.sql | 31 +++ .../sqlkv_insert_legacy_resource_history.sql | 44 ++++ .../resource/data/sqlkv_save_event.sql | 15 ++ .../resource/data/sqlkv_update_datastore.sql | 3 + .../data/sqlkv_update_legacy_resource.sql | 9 + pkg/storage/unified/resource/datastore.go | 44 +++- pkg/storage/unified/resource/eventstore.go | 1 + pkg/storage/unified/resource/sqlkv.go | 249 ++++++++++++++++-- .../unified/resource/storage_backend.go | 59 ++++- .../unified/sql/rvmanager/rv_manager.go | 27 +- pkg/storage/unified/sql/server.go | 27 +- pkg/storage/unified/testing/kv.go | 45 ++-- pkg/storage/unified/testing/kv_test.go | 1 - 15 files changed, 534 insertions(+), 47 deletions(-) create mode 100644 pkg/storage/unified/resource/data/sqlkv_delete_legacy_resource.sql create mode 100644 pkg/storage/unified/resource/data/sqlkv_insert_datastore.sql create mode 100644 pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource.sql create mode 100644 pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource_history.sql create mode 100644 pkg/storage/unified/resource/data/sqlkv_save_event.sql create mode 100644 pkg/storage/unified/resource/data/sqlkv_update_datastore.sql create mode 100644 pkg/storage/unified/resource/data/sqlkv_update_legacy_resource.sql diff --git a/pkg/storage/unified/resource/data/sqlkv_delete_legacy_resource.sql b/pkg/storage/unified/resource/data/sqlkv_delete_legacy_resource.sql new file mode 100644 index 00000000000..e27ee578a41 --- /dev/null +++ b/pkg/storage/unified/resource/data/sqlkv_delete_legacy_resource.sql @@ -0,0 +1,5 @@ +DELETE FROM {{ .Ident "resource" }} +WHERE {{ .Ident "group" }} = {{ .Arg .Group }} +AND {{ .Ident "resource" }} = {{ .Arg .Resource }} +AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }} +AND {{ .Ident "name" }} = {{ .Arg .Name }}; diff --git a/pkg/storage/unified/resource/data/sqlkv_insert_datastore.sql b/pkg/storage/unified/resource/data/sqlkv_insert_datastore.sql new file mode 100644 index 00000000000..8372eb73463 --- /dev/null +++ b/pkg/storage/unified/resource/data/sqlkv_insert_datastore.sql @@ -0,0 +1,21 @@ +INSERT INTO {{ .Ident .TableName }} +( + {{ .Ident "guid" }}, + {{ .Ident "key_path" }}, + {{ .Ident "value" }}, + {{ .Ident "group" }}, + {{ .Ident "resource" }}, + {{ .Ident "namespace" }}, + {{ .Ident "name" }}, + {{ .Ident "action" }} +) +VALUES ( + {{ .Arg .GUID }}, + {{ .Arg .KeyPath }}, + COALESCE({{ .Arg .Value }}, ""), + {{ .Arg .Group }}, + {{ .Arg .Resource }}, + {{ .Arg .Namespace }}, + {{ .Arg .Name }}, + {{ .Arg .Action }} +); diff --git a/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource.sql b/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource.sql new file mode 100644 index 00000000000..1f58bd28b43 --- /dev/null +++ b/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource.sql @@ -0,0 +1,31 @@ +INSERT INTO {{ .Ident "resource" }} +( + {{ .Ident "value" }}, + {{ .Ident "guid" }}, + {{ .Ident "group" }}, + {{ .Ident "resource" }}, + {{ .Ident "namespace" }}, + {{ .Ident "name" }}, + {{ .Ident "action" }}, + {{ .Ident "folder" }}, + {{ .Ident "previous_resource_version" }} +) +VALUES ( + COALESCE({{ .Arg .Value }}, ""), + {{ .Arg .GUID }}, + {{ .Arg .Group }}, + {{ .Arg .Resource }}, + {{ .Arg .Namespace }}, + {{ .Arg .Name }}, + {{ .Arg .Action }}, + {{ .Arg .Folder }}, + CASE WHEN {{ .Arg .Action }} = 1 THEN 0 ELSE ( + SELECT {{ .Ident "resource_version" }} + FROM {{ .Ident "resource" }} + WHERE {{ .Ident "group" }} = {{ .Arg .Group }} + AND {{ .Ident "resource" }} = {{ .Arg .Resource }} + AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }} + AND {{ .Ident "name" }} = {{ .Arg .Name }} + ORDER BY {{ .Ident "resource_version" }} DESC LIMIT 1 + ) END +); diff --git a/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource_history.sql b/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource_history.sql new file mode 100644 index 00000000000..d52aac5063d --- /dev/null +++ b/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource_history.sql @@ -0,0 +1,44 @@ +INSERT INTO {{ .Ident "resource_history" }} +( + {{ .Ident "value" }}, + {{ .Ident "guid" }}, + {{ .Ident "group" }}, + {{ .Ident "resource" }}, + {{ .Ident "namespace" }}, + {{ .Ident "name" }}, + {{ .Ident "action" }}, + {{ .Ident "folder" }}, + {{ .Ident "previous_resource_version" }}, + {{ .Ident "generation" }} +) +VALUES ( + COALESCE({{ .Arg .Value }}, ""), + {{ .Arg .GUID }}, + {{ .Arg .Group }}, + {{ .Arg .Resource }}, + {{ .Arg .Namespace }}, + {{ .Arg .Name }}, + {{ .Arg .Action }}, + {{ .Arg .Folder }}, + CASE WHEN {{ .Arg .Action }} = 1 THEN 0 ELSE ( + SELECT {{ .Ident "resource_version" }} + FROM {{ .Ident "resource_history" }} + WHERE {{ .Ident "group" }} = {{ .Arg .Group }} + AND {{ .Ident "resource" }} = {{ .Arg .Resource }} + AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }} + AND {{ .Ident "name" }} = {{ .Arg .Name }} + ORDER BY {{ .Ident "resource_version" }} DESC LIMIT 1 + ) END, + CASE + WHEN {{ .Arg .Action }} = 1 THEN 1 + WHEN {{ .Arg .Action }} = 3 THEN 0 + ELSE 1 + ( + SELECT COUNT(1) + FROM {{ .Ident "resource_history" }} + WHERE {{ .Ident "group" }} = {{ .Arg .Group }} + AND {{ .Ident "resource" }} = {{ .Arg .Resource }} + AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }} + AND {{ .Ident "name" }} = {{ .Arg .Name }} + ) + END +); diff --git a/pkg/storage/unified/resource/data/sqlkv_save_event.sql b/pkg/storage/unified/resource/data/sqlkv_save_event.sql new file mode 100644 index 00000000000..669497dbb19 --- /dev/null +++ b/pkg/storage/unified/resource/data/sqlkv_save_event.sql @@ -0,0 +1,15 @@ +INSERT INTO {{ .Ident .TableName }} +( + {{ .Ident "key_path" }}, + {{ .Ident "value" }} +) +VALUES ( + {{ .Arg .KeyPath }}, + COALESCE({{ .Arg .Value }}, "") +) +{{- if eq .DialectName "mysql" }} +ON DUPLICATE KEY UPDATE {{ .Ident "value" }} = {{ .Arg .Value }} +{{- else }} +ON CONFLICT ({{ .Ident "key_path" }}) DO UPDATE SET {{ .Ident "value" }} = {{ .Arg .Value }} +{{- end }} +; diff --git a/pkg/storage/unified/resource/data/sqlkv_update_datastore.sql b/pkg/storage/unified/resource/data/sqlkv_update_datastore.sql new file mode 100644 index 00000000000..677666e00a4 --- /dev/null +++ b/pkg/storage/unified/resource/data/sqlkv_update_datastore.sql @@ -0,0 +1,3 @@ +UPDATE {{ .Ident .TableName }} +SET {{ .Ident "value" }} = {{ .Arg .Value }} +WHERE {{ .Ident "key_path" }} = {{ .Arg .KeyPath }}; diff --git a/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource.sql b/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource.sql new file mode 100644 index 00000000000..1565d0894a4 --- /dev/null +++ b/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource.sql @@ -0,0 +1,9 @@ +UPDATE {{ .Ident "resource" }} +SET + {{ .Ident "value" }} = {{ .Arg .Value }}, + {{ .Ident "action" }} = {{ .Arg .Action }}, + {{ .Ident "folder" }} = {{ .Arg .Folder }} +WHERE {{ .Ident "group" }} = {{ .Arg .Group }} +AND {{ .Ident "resource" }} = {{ .Arg .Resource }} +AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }} +AND {{ .Ident "name" }} = {{ .Arg .Name }}; diff --git a/pkg/storage/unified/resource/datastore.go b/pkg/storage/unified/resource/datastore.go index 8a930492420..7a1b614323e 100644 --- a/pkg/storage/unified/resource/datastore.go +++ b/pkg/storage/unified/resource/datastore.go @@ -49,6 +49,9 @@ type DataKey struct { ResourceVersion int64 Action DataAction Folder string + + // needed to maintain backwards compatibility with unified/sql + GUID string } // GroupResource represents a unique group/resource combination @@ -61,6 +64,12 @@ func (k DataKey) String() string { return fmt.Sprintf("%s/%s/%s/%s/%d~%s~%s", k.Group, k.Resource, k.Namespace, k.Name, k.ResourceVersion, k.Action, k.Folder) } +// Temporary while we need to support unified/sql/backend compatibility +// Remove once we stop using RvManager in storage_backend.go +func (k DataKey) StringWithGUID() string { + return fmt.Sprintf("%s/%s/%s/%s/%d~%s~%s~%s", k.Group, k.Resource, k.Namespace, k.Name, k.ResourceVersion, k.Action, k.Folder, k.GUID) +} + func (k DataKey) Equals(other DataKey) bool { return k.Group == other.Group && k.Resource == other.Resource && k.Namespace == other.Namespace && k.Name == other.Name && k.ResourceVersion == other.ResourceVersion && k.Action == other.Action && k.Folder == other.Folder } @@ -516,7 +525,13 @@ func (d *dataStore) Save(ctx context.Context, key DataKey, value io.Reader) erro return fmt.Errorf("invalid data key: %w", err) } - writer, err := d.kv.Save(ctx, dataSection, key.String()) + var writer io.WriteCloser + var err error + if key.GUID != "" { + writer, err = d.kv.Save(ctx, dataSection, key.StringWithGUID()) + } else { + writer, err = d.kv.Save(ctx, dataSection, key.String()) + } if err != nil { return err } @@ -583,6 +598,33 @@ func ParseKey(key string) (DataKey, error) { }, nil } +// Temporary while we need to support unified/sql/backend compatibility +// Remove once we stop using RvManager in storage_backend.go +func ParseKeyWithGUID(key string) (DataKey, error) { + parts := strings.Split(key, "/") + if len(parts) != 5 { + return DataKey{}, fmt.Errorf("invalid key: %s", key) + } + rvActionFolderGUIDParts := strings.Split(parts[4], "~") + if len(rvActionFolderGUIDParts) != 4 { + return DataKey{}, fmt.Errorf("invalid key: %s", key) + } + rv, err := strconv.ParseInt(rvActionFolderGUIDParts[0], 10, 64) + if err != nil { + return DataKey{}, fmt.Errorf("invalid resource version '%s' in key %s: %w", rvActionFolderGUIDParts[0], key, err) + } + return DataKey{ + Group: parts[0], + Resource: parts[1], + Namespace: parts[2], + Name: parts[3], + ResourceVersion: rv, + Action: DataAction(rvActionFolderGUIDParts[1]), + Folder: rvActionFolderGUIDParts[2], + GUID: rvActionFolderGUIDParts[3], + }, nil +} + // SameResource checks if this key represents the same resource as another key. // It compares the identifying fields: Group, Resource, Namespace, and Name. // ResourceVersion, Action, and Folder are ignored as they don't identify the resource itself. diff --git a/pkg/storage/unified/resource/eventstore.go b/pkg/storage/unified/resource/eventstore.go index 7f80fb6b87a..e0c01afb550 100644 --- a/pkg/storage/unified/resource/eventstore.go +++ b/pkg/storage/unified/resource/eventstore.go @@ -32,6 +32,7 @@ type EventKey struct { ResourceVersion int64 Action DataAction Folder string + GUID string } func (k EventKey) String() string { diff --git a/pkg/storage/unified/resource/sqlkv.go b/pkg/storage/unified/resource/sqlkv.go index 3c07403296b..9cc2cc32dd0 100644 --- a/pkg/storage/unified/resource/sqlkv.go +++ b/pkg/storage/unified/resource/sqlkv.go @@ -12,8 +12,10 @@ import ( "strings" "text/template" + "github.com/google/uuid" "github.com/grafana/grafana/pkg/storage/unified/sql/db" "github.com/grafana/grafana/pkg/storage/unified/sql/dbutil" + "github.com/grafana/grafana/pkg/storage/unified/sql/rvmanager" "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" ) @@ -34,11 +36,18 @@ func mustTemplate(filename string) *template.Template { // Templates. var ( - sqlKVKeys = mustTemplate("sqlkv_keys.sql") - sqlKVGet = mustTemplate("sqlkv_get.sql") - sqlKVBatchGet = mustTemplate("sqlkv_batch_get.sql") - sqlKVDelete = mustTemplate("sqlkv_delete.sql") - sqlKVBatchDelete = mustTemplate("sqlkv_batch_delete.sql") + sqlKVKeys = mustTemplate("sqlkv_keys.sql") + sqlKVGet = mustTemplate("sqlkv_get.sql") + sqlKVBatchGet = mustTemplate("sqlkv_batch_get.sql") + sqlKVSaveEvent = mustTemplate("sqlkv_save_event.sql") + sqlKVInsertData = mustTemplate("sqlkv_insert_datastore.sql") + sqlKVUpdateData = mustTemplate("sqlkv_update_datastore.sql") + sqlKVInsertLegacyResourceHistory = mustTemplate("sqlkv_insert_legacy_resource_history.sql") + sqlKVInsertLegacyResource = mustTemplate("sqlkv_insert_legacy_resource.sql") + sqlKVUpdateLegacyResource = mustTemplate("sqlkv_update_legacy_resource.sql") + sqlKVDeleteLegacyResource = mustTemplate("sqlkv_delete_legacy_resource.sql") + sqlKVDelete = mustTemplate("sqlkv_delete.sql") + sqlKVBatchDelete = mustTemplate("sqlkv_batch_delete.sql") ) // sqlKVSection can be embedded in structs used when rendering query templates @@ -128,6 +137,45 @@ func (req sqlKVBatchRequest) KeyPaths() []string { return result } +type sqlKVSaveRequest struct { + sqltemplate.SQLTemplate + sqlKVSectionKey + Value []byte + + // old fields that can be removed once we prune resource_history + GUID string + Group string + Resource string + Namespace string + Name string + Action int64 + Folder string +} + +func (req sqlKVSaveRequest) Validate() error { + return req.sqlKVSectionKey.Validate() +} + +type sqlKVLegacySaveRequest struct { + sqltemplate.SQLTemplate + Value []byte + GUID string + Group string + Resource string + Namespace string + Name string + Action int64 + Folder string +} + +func (req sqlKVLegacySaveRequest) Validate() error { + return nil +} + +func (req sqlKVLegacySaveRequest) Results() ([]byte, error) { + return req.Value, nil +} + type sqlKVKeysRequest struct { sqltemplate.SQLTemplate sqlKVSection @@ -285,20 +333,187 @@ func (k *sqlKV) BatchGet(ctx context.Context, section string, keys []string) ite } } -// TODO: this function only exists to support the testing of the sqlkv implementation before -// we have a proper implementation of `Save`. -func (k *sqlKV) TestingSave(ctx context.Context, key string, value []byte) error { - stmt := fmt.Sprintf( - `INSERT INTO resource_events (key_path, value) VALUES (%s, %s)`, - k.dialect.ArgPlaceholder(1), k.dialect.ArgPlaceholder(2), - ) +func (k *sqlKV) Save(ctx context.Context, section string, key string) (io.WriteCloser, error) { + sectionKey := sqlKVSectionKey{sqlKVSection{section}, key} + if err := sectionKey.Validate(); err != nil { + return nil, err + } - _, err := k.db.ExecContext(ctx, stmt, eventsSection+"/"+key, value) - return err + return &sqlWriteCloser{ + kv: k, + ctx: ctx, + sectionKey: sectionKey, + buf: &bytes.Buffer{}, + closed: false, + }, nil } -func (k *sqlKV) Save(ctx context.Context, section string, key string) (io.WriteCloser, error) { - panic("not implemented!") +type sqlWriteCloser struct { + kv *sqlKV + ctx context.Context + sectionKey sqlKVSectionKey + buf *bytes.Buffer + closed bool +} + +func (w *sqlWriteCloser) Write(value []byte) (int, error) { + if w.closed { + return 0, errors.New("write to closed writer") + } + + return w.buf.Write(value) +} + +func (w *sqlWriteCloser) Close() error { + if w.closed { + return nil + } + + w.closed = true + + // do regular kv save: simple key_path + value insert with conflict check. + // can only do this on resource_events for now, until we drop the columns in resource_history + if w.sectionKey.Section == eventsSection { + _, err := dbutil.Exec(w.ctx, w.kv.db, sqlKVSaveEvent, sqlKVSaveRequest{ + SQLTemplate: sqltemplate.New(w.kv.dialect), + sqlKVSectionKey: w.sectionKey, + Value: w.buf.Bytes(), + }) + + if err != nil { + return fmt.Errorf("failed to save: %w", err) + } + + return nil + } + + // if storage_backend is running with an RvManager, it will inject a transaction into the context + // used to keep backwards compatibility between sql-based kvstore and unified/sql/backend + tx, ok := rvmanager.TxFromCtx(w.ctx) + if !ok { + // temporary save for dataStore without rvmanager + // we can use the same template as the event one after we: + // - move PK from GUID to key_path + // - remove all unnecessary columns (or at least their NOT NULL constraints) + _, err := w.kv.Get(w.ctx, w.sectionKey.Section, w.sectionKey.Key) + if errors.Is(err, ErrNotFound) { + _, err := dbutil.Exec(w.ctx, w.kv.db, sqlKVInsertData, sqlKVSaveRequest{ + SQLTemplate: sqltemplate.New(w.kv.dialect), + sqlKVSectionKey: w.sectionKey, + GUID: uuid.New().String(), + Value: w.buf.Bytes(), + }) + + if err != nil { + return fmt.Errorf("failed to insert to datastore: %w", err) + } + + return nil + } + + if err != nil { + return fmt.Errorf("failed to get for save: %w", err) + } + + _, err = dbutil.Exec(w.ctx, w.kv.db, sqlKVUpdateData, sqlKVSaveRequest{ + SQLTemplate: sqltemplate.New(w.kv.dialect), + sqlKVSectionKey: w.sectionKey, + Value: w.buf.Bytes(), + }) + + if err != nil { + return fmt.Errorf("failed to update to datastore: %w", err) + } + + return nil + } + + // special, temporary save that includes all the fields in resource_history that are not relevant for the kvstore, + // as well as the resource table. This is only called if an RvManager was passed to storage_backend, as that + // component will be responsible for populating the resource_version and key_path columns + // note that we are not touching resource_version table, neither the resource_version columns or the key_path column + // as the RvManager will be responsible for this + dataKey, err := ParseKeyWithGUID(w.sectionKey.Key) + if err != nil { + return fmt.Errorf("failed to parse key: %w", err) + } + + var action int64 + switch dataKey.Action { + case DataActionCreated: + action = 1 + case DataActionUpdated: + action = 2 + case DataActionDeleted: + action = 3 + default: + return fmt.Errorf("failed to parse key: %w", err) + } + + _, err = dbutil.Exec(w.ctx, tx, sqlKVInsertLegacyResourceHistory, sqlKVSaveRequest{ + SQLTemplate: sqltemplate.New(w.kv.dialect), + sqlKVSectionKey: w.sectionKey, + Value: w.buf.Bytes(), + GUID: dataKey.GUID, + Group: dataKey.Group, + Resource: dataKey.Resource, + Namespace: dataKey.Namespace, + Name: dataKey.Name, + Action: action, + Folder: dataKey.Folder, + }) + + if err != nil { + return fmt.Errorf("failed to save to resource_history: %w", err) + } + + switch dataKey.Action { + case DataActionCreated: + _, err = dbutil.Exec(w.ctx, tx, sqlKVInsertLegacyResource, sqlKVLegacySaveRequest{ + SQLTemplate: sqltemplate.New(w.kv.dialect), + Value: w.buf.Bytes(), + GUID: dataKey.GUID, + Group: dataKey.Group, + Resource: dataKey.Resource, + Namespace: dataKey.Namespace, + Name: dataKey.Name, + Action: action, + Folder: dataKey.Folder, + }) + + if err != nil { + return fmt.Errorf("failed to insert to resource: %w", err) + } + case DataActionUpdated: + _, err = dbutil.Exec(w.ctx, tx, sqlKVUpdateLegacyResource, sqlKVLegacySaveRequest{ + SQLTemplate: sqltemplate.New(w.kv.dialect), + Value: w.buf.Bytes(), + Group: dataKey.Group, + Resource: dataKey.Resource, + Namespace: dataKey.Namespace, + Name: dataKey.Name, + Action: action, + Folder: dataKey.Folder, + }) + + if err != nil { + return fmt.Errorf("failed to update resource: %w", err) + } + case DataActionDeleted: + _, err = dbutil.Exec(w.ctx, tx, sqlKVDeleteLegacyResource, sqlKVLegacySaveRequest{ + SQLTemplate: sqltemplate.New(w.kv.dialect), + Group: dataKey.Group, + Resource: dataKey.Resource, + Namespace: dataKey.Namespace, + Name: dataKey.Name, + }) + + if err != nil { + return fmt.Errorf("failed to delete from resource: %w", err) + } + } + + return nil } func (k *sqlKV) Delete(ctx context.Context, section string, key string) error { @@ -319,6 +534,8 @@ func (k *sqlKV) Delete(ctx context.Context, section string, key string) error { return ErrNotFound } + // TODO reflect change to resource table + return nil } diff --git a/pkg/storage/unified/resource/storage_backend.go b/pkg/storage/unified/resource/storage_backend.go index b0f51702775..13f2b9d6159 100644 --- a/pkg/storage/unified/resource/storage_backend.go +++ b/pkg/storage/unified/resource/storage_backend.go @@ -14,6 +14,7 @@ import ( "time" "github.com/bwmarrin/snowflake" + "github.com/google/uuid" "github.com/grafana/grafana-app-sdk/logging" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel/trace" @@ -22,6 +23,8 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" + "github.com/grafana/grafana/pkg/storage/unified/sql/db" + "github.com/grafana/grafana/pkg/storage/unified/sql/rvmanager" "github.com/grafana/grafana/pkg/util/debouncer" ) @@ -68,6 +71,8 @@ type kvStorageBackend struct { withExperimentalClusterScope bool //tracer trace.Tracer //reg prometheus.Registerer + + rvManager *rvmanager.ResourceVersionManager } var _ KVBackend = &kvStorageBackend{} @@ -85,6 +90,10 @@ type KVBackendOptions struct { EventPruningInterval time.Duration // How often to run the event pruning (default: 5 minutes) Tracer trace.Tracer // TODO add tracing Reg prometheus.Registerer // TODO add metrics + + // Adding RvManager overrides the RV generated with snowflake in order to keep backwards compatibility with + // unified/sql + RvManager *rvmanager.ResourceVersionManager } func NewKVStorageBackend(opts KVBackendOptions) (KVBackend, error) { @@ -119,6 +128,7 @@ func NewKVStorageBackend(opts KVBackendOptions) (KVBackend, error) { eventRetentionPeriod: eventRetentionPeriod, eventPruningInterval: eventPruningInterval, withExperimentalClusterScope: opts.WithExperimentalClusterScope, + rvManager: opts.RvManager, } err = backend.initPruner(ctx) if err != nil { @@ -317,9 +327,28 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in Action: action, Folder: obj.GetFolder(), } - err := k.dataStore.Save(ctx, dataKey, bytes.NewReader(event.Value)) - if err != nil { - return 0, fmt.Errorf("failed to write data: %w", err) + + if k.rvManager != nil { + dataKey.GUID = uuid.New().String() + var err error + rv, err = k.rvManager.ExecWithRV(ctx, event.Key, func(tx db.Tx) (string, error) { + err := k.dataStore.Save(rvmanager.ContextWithTx(ctx, tx), dataKey, bytes.NewReader(event.Value)) + if err != nil { + return "", fmt.Errorf("failed to write data: %w", err) + } + + return dataKey.GUID, nil + }) + if err != nil { + return 0, fmt.Errorf("failed to write data: %w", err) + } + + dataKey.ResourceVersion = rv + } else { + err := k.dataStore.Save(ctx, dataKey, bytes.NewReader(event.Value)) + if err != nil { + return 0, fmt.Errorf("failed to write data: %w", err) + } } // Optimistic concurrency control to verify our write is the latest version @@ -340,14 +369,22 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in } // Check if the RV we just wrote is the latest. If not, a concurrent write with higher RV happened - if latestKey.ResourceVersion != rv { + if !rvmanager.IsRvEqual(latestKey.ResourceVersion, rv) { // Delete the data we just wrote since it's not the latest + // if we're running with rvManager, convert the ResourceVersion back to snowflake to delete + if k.rvManager != nil { + dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion) + } _ = k.dataStore.Delete(ctx, dataKey) return 0, fmt.Errorf("optimistic locking failed: concurrent modification detected") } - if prevKey.ResourceVersion != event.PreviousRV { + if !rvmanager.IsRvEqual(prevKey.ResourceVersion, event.PreviousRV) { // Another concurrent write happened between our read and write + // if we're running with rvManager, convert the ResourceVersion back to snowflake to delete + if k.rvManager != nil { + dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion) + } _ = k.dataStore.Delete(ctx, dataKey) return 0, fmt.Errorf("optimistic locking failed: resource was modified concurrently (expected previous RV %d, found %d)", event.PreviousRV, prevKey.ResourceVersion) } @@ -366,8 +403,12 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in } // Check if the RV we just wrote is the latest. If not, a concurrent create with higher RV happened - if latestKey.ResourceVersion != rv { + if !rvmanager.IsRvEqual(latestKey.ResourceVersion, rv) { // Delete the data we just wrote since it's not the latest + // if we're running with rvManager, convert the ResourceVersion back to snowflake to delete + if k.rvManager != nil { + dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion) + } _ = k.dataStore.Delete(ctx, dataKey) return 0, fmt.Errorf("optimistic locking failed: concurrent create detected") } @@ -375,6 +416,10 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in // Verify that the immediate predecessor is not a create if prevKey.Action == DataActionCreated { // Another concurrent create happened - delete our write and return error + // if we're running with rvManager, convert the ResourceVersion back to snowflake to delete + if k.rvManager != nil { + dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion) + } _ = k.dataStore.Delete(ctx, dataKey) return 0, fmt.Errorf("optimistic locking failed: concurrent create detected") } @@ -391,7 +436,7 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in Folder: obj.GetFolder(), PreviousRV: event.PreviousRV, } - err = k.eventStore.Save(ctx, eventData) + err := k.eventStore.Save(ctx, eventData) if err != nil { // Clean up the data we wrote since event save failed _ = k.dataStore.Delete(ctx, dataKey) diff --git a/pkg/storage/unified/sql/rvmanager/rv_manager.go b/pkg/storage/unified/sql/rvmanager/rv_manager.go index b4f3b5de596..b10685f22ad 100644 --- a/pkg/storage/unified/sql/rvmanager/rv_manager.go +++ b/pkg/storage/unified/sql/rvmanager/rv_manager.go @@ -21,6 +21,19 @@ import ( "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" ) +type contextKey string + +const txKey contextKey = "rvmanager_db_tx" + +func ContextWithTx(ctx context.Context, tx db.Tx) context.Context { + return context.WithValue(ctx, txKey, tx) +} + +func TxFromCtx(ctx context.Context) (db.Tx, bool) { + tx, ok := ctx.Value(txKey).(db.Tx) + return tx, ok +} + var tracer = otel.Tracer("github.com/grafana/grafana/pkg/storage/unified/sql/rvmanager") var ( @@ -294,7 +307,7 @@ func (m *ResourceVersionManager) execBatch(ctx context.Context, group, resource // Allocate the RVs for i, guid := range guids { guidToRV[guid] = rv - guidToSnowflakeRV[guid] = snowflakeFromRv(rv) + guidToSnowflakeRV[guid] = SnowflakeFromRv(rv) rvs[i] = rv rv++ } @@ -353,10 +366,20 @@ func (m *ResourceVersionManager) execBatch(ctx context.Context, group, resource // takes a unix microsecond rv and transforms into a snowflake format. The timestamp is converted from microsecond to // millisecond (the integer division) and the remainder is saved in the stepbits section. machine id is always 0 -func snowflakeFromRv(rv int64) int64 { +func SnowflakeFromRv(rv int64) int64 { return (((rv / 1000) - snowflake.Epoch) << (snowflake.NodeBits + snowflake.StepBits)) + (rv % 1000) } +// helper utility to compare two RVs. The first RV must be in snowflake format. Will convert rv2 to snowflake and retry +// if comparison fails +func IsRvEqual(rv1, rv2 int64) bool { + if rv1 == rv2 { + return true + } + + return rv1 == SnowflakeFromRv(rv2) +} + // Lock locks the resource version for the given key func (m *ResourceVersionManager) Lock(ctx context.Context, x db.ContextExecer, group, resource string) (nextRV int64, err error) { // 1. Lock the row and prevent concurrent updates until the transaction is committed diff --git a/pkg/storage/unified/sql/server.go b/pkg/storage/unified/sql/server.go index 84eda71ca20..f4a1ee3ce77 100644 --- a/pkg/storage/unified/sql/server.go +++ b/pkg/storage/unified/sql/server.go @@ -20,6 +20,8 @@ import ( "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl" + "github.com/grafana/grafana/pkg/storage/unified/sql/rvmanager" + "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" ) type QOSEnqueueDequeuer interface { @@ -103,11 +105,34 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) { return nil, fmt.Errorf("error creating sqlkv: %s", err) } - kvBackend, err := resource.NewKVStorageBackend(resource.KVBackendOptions{ + kvBackendOpts := resource.KVBackendOptions{ KvStore: sqlkv, Tracer: opts.Tracer, Reg: opts.Reg, + } + + ctx := context.Background() + dbConn, err := eDB.Init(ctx) + if err != nil { + return nil, fmt.Errorf("error initializing DB: %w", err) + } + + dialect := sqltemplate.DialectForDriver(dbConn.DriverName()) + if dialect == nil { + return nil, fmt.Errorf("unsupported database driver: %s", dbConn.DriverName()) + } + + rvManager, err := rvmanager.NewResourceVersionManager(rvmanager.ResourceManagerOptions{ + Dialect: dialect, + DB: dbConn, }) + if err != nil { + return nil, fmt.Errorf("failed to create resource version manager: %w", err) + } + + // TODO add config to decide whether to pass RvManager or not + kvBackendOpts.RvManager = rvManager + kvBackend, err := resource.NewKVStorageBackend(kvBackendOpts) if err != nil { return nil, fmt.Errorf("error creating kv backend: %s", err) } diff --git a/pkg/storage/unified/testing/kv.go b/pkg/storage/unified/testing/kv.go index 770bf9ac6e6..d1900c7a46e 100644 --- a/pkg/storage/unified/testing/kv.go +++ b/pkg/storage/unified/testing/kv.go @@ -148,14 +148,13 @@ func runTestKVGet(t *testing.T, kv resource.KV, nsPrefix string) { func runTestKVSave(t *testing.T, kv resource.KV, nsPrefix string) { ctx := testutil.NewTestContext(t, time.Now().Add(30*time.Second)) - section := nsPrefix + "-save" t.Run("save new key", func(t *testing.T) { testValue := "new test value" - saveKVHelper(t, kv, ctx, section, "new-key", strings.NewReader(testValue)) + saveKVHelper(t, kv, ctx, testSection, "new-key", strings.NewReader(testValue)) // Verify it was saved - reader, err := kv.Get(ctx, section, "new-key") + reader, err := kv.Get(ctx, testSection, "new-key") require.NoError(t, err) value, err := io.ReadAll(reader) @@ -166,6 +165,26 @@ func runTestKVSave(t *testing.T, kv resource.KV, nsPrefix string) { }) t.Run("save overwrite existing key", func(t *testing.T) { + // First save + saveKVHelper(t, kv, ctx, testSection, "overwrite-key", strings.NewReader("old value")) + + // Overwrite + newValue := "new value" + saveKVHelper(t, kv, ctx, testSection, "overwrite-key", strings.NewReader(newValue)) + + // Verify it was updated + reader, err := kv.Get(ctx, testSection, "overwrite-key") + require.NoError(t, err) + + value, err := io.ReadAll(reader) + require.NoError(t, err) + assert.Equal(t, newValue, string(value)) + err = reader.Close() + require.NoError(t, err) + }) + + t.Run("save overwrite existing key (datastore)", func(t *testing.T) { + section := "unified/data" // First save saveKVHelper(t, kv, ctx, section, "overwrite-key", strings.NewReader("old value")) @@ -192,10 +211,10 @@ func runTestKVSave(t *testing.T, kv resource.KV, nsPrefix string) { t.Run("save binary data", func(t *testing.T) { binaryData := []byte{0x00, 0x01, 0x02, 0x03, 0xFF, 0xFE, 0xFD} - saveKVHelper(t, kv, ctx, section, "binary-key", bytes.NewReader(binaryData)) + saveKVHelper(t, kv, ctx, testSection, "binary-key", bytes.NewReader(binaryData)) // Verify binary data - reader, err := kv.Get(ctx, section, "binary-key") + reader, err := kv.Get(ctx, testSection, "binary-key") require.NoError(t, err) value, err := io.ReadAll(reader) @@ -207,10 +226,10 @@ func runTestKVSave(t *testing.T, kv resource.KV, nsPrefix string) { t.Run("save key with no data", func(t *testing.T) { // Save a key with empty data - saveKVHelper(t, kv, ctx, section, "empty-key", strings.NewReader("")) + saveKVHelper(t, kv, ctx, testSection, "empty-key", strings.NewReader("")) // Verify it was saved with empty data - reader, err := kv.Get(ctx, section, "empty-key") + reader, err := kv.Get(ctx, testSection, "empty-key") require.NoError(t, err) value, err := io.ReadAll(reader) @@ -907,18 +926,6 @@ func runTestKVBatchDelete(t *testing.T, kv resource.KV, nsPrefix string) { func saveKVHelper(t *testing.T, kv resource.KV, ctx context.Context, section, key string, value io.Reader) { t.Helper() - // TODO: remove this check once the sqlkv implementation supports `Save`. - type testingSaver interface { - TestingSave(context.Context, string, []byte) error - } - - if saver, ok := kv.(testingSaver); ok { - blob, err := io.ReadAll(value) - require.NoError(t, err) - require.NoError(t, saver.TestingSave(ctx, key, blob)) - return - } - writer, err := kv.Save(ctx, section, key) require.NoError(t, err) _, err = io.Copy(writer, value) diff --git a/pkg/storage/unified/testing/kv_test.go b/pkg/storage/unified/testing/kv_test.go index 3814df17b7e..5e94ccd8a7f 100644 --- a/pkg/storage/unified/testing/kv_test.go +++ b/pkg/storage/unified/testing/kv_test.go @@ -47,7 +47,6 @@ func TestSQLKV(t *testing.T) { }, &KVTestOptions{ NSPrefix: "sql-kv-test", SkipTests: map[string]bool{ - TestKVSave: true, TestKVConcurrent: true, TestKVUnixTimestamp: true, }, From 2fbe2f77e389d59ea84a63fb095d3bc887e72009 Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Fri, 19 Dec 2025 13:17:39 -0700 Subject: [PATCH 065/229] Folders: Add max depth check with descendant to /apis (#115305) --- pkg/api/apierrors/folder.go | 3 +- pkg/api/apierrors/folder_test.go | 2 +- pkg/api/folder.go | 2 +- pkg/registry/apis/folders/register.go | 2 +- pkg/registry/apis/folders/register_test.go | 4 + pkg/registry/apis/folders/validate.go | 162 +++++++++++++++++- pkg/registry/apis/folders/validate_test.go | 113 +++++++++++- .../folderimpl/folder_unifiedstorage.go | 38 +--- 8 files changed, 275 insertions(+), 51 deletions(-) diff --git a/pkg/api/apierrors/folder.go b/pkg/api/apierrors/folder.go index 9509ff4ff55..a938b165852 100644 --- a/pkg/api/apierrors/folder.go +++ b/pkg/api/apierrors/folder.go @@ -29,7 +29,8 @@ func ToFolderErrorResponse(err error) response.Response { errors.Is(err, dashboards.ErrDashboardTypeMismatch) || errors.Is(err, dashboards.ErrDashboardInvalidUid) || errors.Is(err, dashboards.ErrDashboardUidTooLong) || - errors.Is(err, folder.ErrFolderCannotBeParentOfItself) { + errors.Is(err, folder.ErrFolderCannotBeParentOfItself) || + errors.Is(err, folder.ErrMaximumDepthReached) { return response.Error(http.StatusBadRequest, err.Error(), nil) } diff --git a/pkg/api/apierrors/folder_test.go b/pkg/api/apierrors/folder_test.go index 0ca8b16fc87..233254dc6cc 100644 --- a/pkg/api/apierrors/folder_test.go +++ b/pkg/api/apierrors/folder_test.go @@ -30,7 +30,7 @@ func TestToFolderErrorResponse(t *testing.T) { { name: "maximum depth reached", input: folder.ErrMaximumDepthReached.Errorf("Maximum nested folder depth reached"), - want: response.Err(folder.ErrMaximumDepthReached.Errorf("Maximum nested folder depth reached")), + want: response.Error(http.StatusBadRequest, "[folder.maximum-depth-reached] Maximum nested folder depth reached", nil), }, { name: "bad request errors", diff --git a/pkg/api/folder.go b/pkg/api/folder.go index 851910a9e1d..5b0bf9d121e 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -214,7 +214,7 @@ func (hs *HTTPServer) MoveFolder(c *contextmodel.ReqContext) response.Response { cmd.SignedInUser = c.SignedInUser theFolder, err := hs.folderService.Move(c.Req.Context(), &cmd) if err != nil { - return response.ErrOrFallback(http.StatusInternalServerError, "move folder failed", err) + return apierrors.ToFolderErrorResponse(err) } folderDTO, err := hs.newToFolderDto(c, theFolder) diff --git a/pkg/registry/apis/folders/register.go b/pkg/registry/apis/folders/register.go index 64e51c06312..7c39fbe22ae 100644 --- a/pkg/registry/apis/folders/register.go +++ b/pkg/registry/apis/folders/register.go @@ -356,7 +356,7 @@ func (b *FolderAPIBuilder) Validate(ctx context.Context, a admission.Attributes, if !ok { return fmt.Errorf("obj is not folders.Folder") } - return validateOnUpdate(ctx, f, old, b.storage, b.parents, folder.MaxNestedFolderDepth) + return validateOnUpdate(ctx, f, old, b.storage, b.parents, b.searcher, folder.MaxNestedFolderDepth) default: return nil } diff --git a/pkg/registry/apis/folders/register_test.go b/pkg/registry/apis/folders/register_test.go index 066f8793776..1eb00f0a7e5 100644 --- a/pkg/registry/apis/folders/register_test.go +++ b/pkg/registry/apis/folders/register_test.go @@ -376,6 +376,10 @@ func TestFolderAPIBuilder_Validate_Update(t *testing.T) { m.On("Get", mock.Anything, "new-parent", mock.Anything).Return( &folders.Folder{}, nil).Once() + // also retrieves old parent for depth difference calculation + m.On("Get", mock.Anything, "valid-parent", mock.Anything).Return( + &folders.Folder{}, + nil).Once() }, }, { diff --git a/pkg/registry/apis/folders/validate.go b/pkg/registry/apis/folders/validate.go index eb0c29b30a1..3c7c30a1aea 100644 --- a/pkg/registry/apis/folders/validate.go +++ b/pkg/registry/apis/folders/validate.go @@ -6,6 +6,7 @@ import ( "slices" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/selection" "k8s.io/apiserver/pkg/registry/rest" folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" @@ -13,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" "github.com/grafana/grafana/pkg/util" ) @@ -73,6 +75,7 @@ func validateOnUpdate(ctx context.Context, old *folders.Folder, getter rest.Getter, parents parentsGetter, + searcher resourcepb.ResourceIndexClient, maxDepth int, ) error { folderObj, err := utils.MetaAccessor(obj) @@ -95,7 +98,10 @@ func validateOnUpdate(ctx context.Context, // Validate the move operation newParent := folderObj.GetFolder() - // If we move to root, we don't need to validate the depth. + // If we move to root, we don't need to validate the depth, because the folder already existed + // before and wasn't too deep. This move will make it more shallow. + // + // We also don't need to validate circular references because the root folder cannot have a parent. if newParent == folder.RootFolderUID { return nil } @@ -113,9 +119,6 @@ func validateOnUpdate(ctx context.Context, if !ok { return fmt.Errorf("expected folder, found %T", parentObj) } - - //FIXME: until we have a way to represent the tree, we can only - // look at folder parents to check how deep the new folder tree will be info, err := parents(ctx, parent) if err != nil { return err @@ -129,13 +132,162 @@ func validateOnUpdate(ctx context.Context, } } - // if by moving a folder we exceed the max depth, return an error + // if by moving a folder we exceed the max depth just from its parents + itself, return an error if len(info.Items) > maxDepth+1 { return folder.ErrMaximumDepthReached.Errorf("maximum folder depth reached") } + // To try to save some computation, get the parents of the old parent (this is typically cheaper + // than looking at the children of the folder). If the old parent has more parents or the same + // number of parents as the new parent, we can return early, because we know the folder had to be + // safe from the creation validation. If we cannot access the older parent, we will continue to check the children. + if canSkipChildrenCheck(ctx, oldFolder, getter, parents, len(info.Items)) { + return nil + } + + // Now comes the more expensive part: we need to check if moving this folder will cause + // any descendant folders to exceed the max depth. + // + // Calculate the maximum allowed subtree depth after the move. + allowedDepth := (maxDepth + 1) - len(info.Items) + if allowedDepth <= 0 { + return nil + } + + return checkSubtreeDepth(ctx, searcher, obj.Namespace, obj.Name, allowedDepth, maxDepth) +} + +// canSkipChildrenCheck determines if we can skip the expensive children depth check. +// If the old parent depth is >= the new parent depth, the folder was already valid +// and this move won't make descendants exceed max depth. +func canSkipChildrenCheck(ctx context.Context, oldFolder utils.GrafanaMetaAccessor, getter rest.Getter, parents parentsGetter, newParentDepth int) bool { + if oldFolder.GetFolder() == folder.RootFolderUID { + return false + } + + oldParentObj, err := getter.Get(ctx, oldFolder.GetFolder(), &metav1.GetOptions{}) + if err != nil { + return false + } + + oldParent, ok := oldParentObj.(*folders.Folder) + if !ok { + return false + } + + oldInfo, err := parents(ctx, oldParent) + if err != nil { + return false + } + + oldParentDepth := len(oldInfo.Items) + levelDifference := newParentDepth - oldParentDepth + return levelDifference <= 0 +} + +// checkSubtreeDepth uses a hybrid DFS+batching approach: +// 1. fetches one page of children for the current folder(s) +// 2. batches all those children into one request to get their children +// 3. continues depth-first (batching still) until max depth or violation +// 4. only fetches more siblings after fully exploring current batch +func checkSubtreeDepth(ctx context.Context, searcher resourcepb.ResourceIndexClient, namespace string, folderUID string, remainingDepth int, maxDepth int) error { + if remainingDepth <= 0 { + return nil + } + + // Start with the folder being moved + return checkSubtreeDepthBatched(ctx, searcher, namespace, []string{folderUID}, remainingDepth, maxDepth) +} + +// checkSubtreeDepthBatched checks depth for a batch of folders at the same level +func checkSubtreeDepthBatched(ctx context.Context, searcher resourcepb.ResourceIndexClient, namespace string, parentUIDs []string, remainingDepth int, maxDepth int) error { + if remainingDepth <= 0 || len(parentUIDs) == 0 { + return nil + } + + const pageSize int64 = 1000 + var offset int64 + totalPages := 0 + hasMore := true + + // Using an upper limit to ensure no infinite loops can happen + for hasMore && totalPages < 1000 { + totalPages++ + + var err error + var children []string + children, hasMore, err = getChildrenBatch(ctx, searcher, namespace, parentUIDs, pageSize, offset) + if err != nil { + return fmt.Errorf("failed to get children: %w", err) + } + + if len(children) == 0 { + return nil + } + + // if we are at the last allowed depth and children exist, we will hit the max + if remainingDepth == 1 { + return folder.ErrMaximumDepthReached.Errorf("maximum folder depth %d would be exceeded after move", maxDepth) + } + + if err := checkSubtreeDepthBatched(ctx, searcher, namespace, children, remainingDepth-1, maxDepth); err != nil { + return err + } + + if !hasMore { + return nil + } + + offset += pageSize + } + return nil } +// getChildrenBatch fetches children for multiple parents +func getChildrenBatch(ctx context.Context, searcher resourcepb.ResourceIndexClient, namespace string, parentUIDs []string, limit int64, offset int64) ([]string, bool, error) { + if len(parentUIDs) == 0 { + return nil, false, nil + } + + resp, err := searcher.Search(ctx, &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{ + Key: &resourcepb.ResourceKey{ + Namespace: namespace, + Group: folders.FolderResourceInfo.GroupVersionResource().Group, + Resource: folders.FolderResourceInfo.GroupVersionResource().Resource, + }, + Fields: []*resourcepb.Requirement{{ + Key: resource.SEARCH_FIELD_FOLDER, + Operator: string(selection.In), + Values: parentUIDs, + }}, + }, + Limit: limit, + Offset: offset, + }) + if err != nil { + return nil, false, fmt.Errorf("failed to search folders: %w", err) + } + + if resp.Error != nil { + return nil, false, fmt.Errorf("search error: %s", resp.Error.Message) + } + + if resp.Results == nil || len(resp.Results.Rows) == 0 { + return nil, false, nil + } + + children := make([]string, 0, len(resp.Results.Rows)) + for _, row := range resp.Results.Rows { + if row.Key != nil { + children = append(children, row.Key.Name) + } + } + + hasMore := resp.Results.NextPageToken != "" + return children, hasMore, nil +} + func validateOnDelete(ctx context.Context, f *folders.Folder, searcher resourcepb.ResourceIndexClient, diff --git a/pkg/registry/apis/folders/validate_test.go b/pkg/registry/apis/folders/validate_test.go index 7fdb3cfae12..b9f55571ce0 100644 --- a/pkg/registry/apis/folders/validate_test.go +++ b/pkg/registry/apis/folders/validate_test.go @@ -282,6 +282,7 @@ func TestValidateUpdate(t *testing.T) { old *folders.Folder parents *folders.FolderInfoList parentsError error + allFolders []folders.Folder expectedErr string maxDepth int // defaults to 5 unless set }{ @@ -454,6 +455,74 @@ func TestValidateUpdate(t *testing.T) { }, expectedErr: "cannot move folder under its own descendant", }, + { + name: "error when moving folder from root to level2 with children exceeds max depth", + folder: &folders.Folder{ + ObjectMeta: metav1.ObjectMeta{ + Name: "folderWithChildren", + Annotations: map[string]string{ + utils.AnnoKeyFolder: "level2", + }, + }, + Spec: folders.FolderSpec{ + Title: "folder with children", + }, + }, + old: &folders.Folder{ + ObjectMeta: metav1.ObjectMeta{ + Name: "folderWithChildren", + }, + Spec: folders.FolderSpec{ + Title: "folder with children", + }, + }, + parents: &folders.FolderInfoList{ + Items: []folders.FolderInfo{ + {Name: "level2", Parent: "level1"}, + {Name: "level1", Parent: folder.GeneralFolderUID}, + {Name: folder.GeneralFolderUID}, + }, + }, + allFolders: []folders.Folder{ + {ObjectMeta: metav1.ObjectMeta{Name: "child1", Annotations: map[string]string{utils.AnnoKeyFolder: "folderWithChildren"}}}, + {ObjectMeta: metav1.ObjectMeta{Name: "grandchild1", Annotations: map[string]string{utils.AnnoKeyFolder: "child1"}}}, + }, + maxDepth: 4, + expectedErr: "[folder.maximum-depth-reached]", + }, + { + name: "can move folder from root level to level1 with children when within max depth", + folder: &folders.Folder{ + ObjectMeta: metav1.ObjectMeta{ + Name: "folderWithChildren", + Annotations: map[string]string{ + utils.AnnoKeyFolder: "level1", + }, + }, + Spec: folders.FolderSpec{ + Title: "folder with children", + }, + }, + old: &folders.Folder{ + ObjectMeta: metav1.ObjectMeta{ + Name: "folderWithChildren", + }, + Spec: folders.FolderSpec{ + Title: "folder with children", + }, + }, + parents: &folders.FolderInfoList{ + Items: []folders.FolderInfo{ + {Name: "level1", Parent: folder.GeneralFolderUID}, + {Name: folder.GeneralFolderUID}, + }, + }, + allFolders: []folders.Folder{ + {ObjectMeta: metav1.ObjectMeta{Name: "child1", Annotations: map[string]string{utils.AnnoKeyFolder: "folderWithChildren"}}}, + {ObjectMeta: metav1.ObjectMeta{Name: "grandchild1", Annotations: map[string]string{utils.AnnoKeyFolder: "child1"}}}, + }, + maxDepth: 4, + }, } for _, tt := range tests { @@ -474,11 +543,17 @@ func TestValidateUpdate(t *testing.T) { }, nil).Maybe() } } + for i := range tt.allFolders { + f := tt.allFolders[i] + m.On("Get", context.Background(), f.Name, &metav1.GetOptions{}).Return(&f, nil).Maybe() + } err := validateOnUpdate(context.Background(), tt.folder, tt.old, m, func(ctx context.Context, folder *folders.Folder) (*folders.FolderInfoList, error) { return tt.parents, tt.parentsError - }, maxDepth) + }, + &mockSearchClient{folders: tt.allFolders}, + maxDepth) if tt.expectedErr == "" { require.NoError(t, err) @@ -693,8 +768,7 @@ type mockSearchClient struct { stats *resourcepb.ResourceStatsResponse statsErr error - search *resourcepb.ResourceSearchResponse - searchErr error + folders []folders.Folder } // GetStats implements resourcepb.ResourceIndexClient. @@ -703,8 +777,37 @@ func (m *mockSearchClient) GetStats(ctx context.Context, in *resourcepb.Resource } // Search implements resourcepb.ResourceIndexClient. -func (m *mockSearchClient) Search(ctx context.Context, in *resourcepb.ResourceSearchRequest, opts ...grpc.CallOption) (*resourcepb.ResourceSearchResponse, error) { - return m.search, m.searchErr +func (m *mockSearchClient) Search(ctx context.Context, req *resourcepb.ResourceSearchRequest, opts ...grpc.CallOption) (*resourcepb.ResourceSearchResponse, error) { + // get the list of parents from the search request + parentSet := make(map[string]bool) + if req.Options != nil && req.Options.Fields != nil { + for _, field := range req.Options.Fields { + if field.Key == "folder" && field.Operator == "in" { + for _, v := range field.Values { + parentSet[v] = true + } + } + } + } + + // find children that match the parent filter + var rows []*resourcepb.ResourceTableRow + for i := range m.folders { + meta, err := utils.MetaAccessor(&m.folders[i]) + if err != nil { + continue + } + parentUID := meta.GetFolder() + if parentSet[parentUID] { + rows = append(rows, &resourcepb.ResourceTableRow{ + Key: &resourcepb.ResourceKey{Name: m.folders[i].Name}, + }) + } + } + + return &resourcepb.ResourceSearchResponse{ + Results: &resourcepb.ResourceTable{Rows: rows}, + }, nil } // RebuildIndexes implements resourcepb.ResourceIndexClient. diff --git a/pkg/services/folder/folderimpl/folder_unifiedstorage.go b/pkg/services/folder/folderimpl/folder_unifiedstorage.go index 9b238b769fe..69b8cb59311 100644 --- a/pkg/services/folder/folderimpl/folder_unifiedstorage.go +++ b/pkg/services/folder/folderimpl/folder_unifiedstorage.go @@ -726,19 +726,6 @@ func (s *Service) moveOnApiServer(ctx context.Context, cmd *folder.MoveFolderCom return nil, folder.ErrBadRequest.Errorf("k6 project may not be moved") } - f, err := s.unifiedStore.Get(ctx, folder.GetFolderQuery{ - UID: &cmd.UID, - OrgID: cmd.OrgID, - SignedInUser: cmd.SignedInUser, - }) - if err != nil { - return nil, err - } - - if f != nil && f.ParentUID == accesscontrol.K6FolderUID { - return nil, folder.ErrBadRequest.Errorf("k6 project may not be moved") - } - // Check that the user is allowed to move the folder to the destination folder hasAccess, evalErr := s.canMoveViaApiServer(ctx, cmd) if evalErr != nil { @@ -748,30 +735,7 @@ func (s *Service) moveOnApiServer(ctx context.Context, cmd *folder.MoveFolderCom return nil, dashboards.ErrFolderAccessDenied } - // here we get the folder, we need to get the height of current folder - // and the depth of the new parent folder, the sum can't bypass 8 - folderHeight, err := s.unifiedStore.GetHeight(ctx, cmd.UID, cmd.OrgID, &cmd.NewParentUID) - if err != nil { - return nil, err - } - parents, err := s.unifiedStore.GetParents(ctx, folder.GetParentsQuery{UID: cmd.NewParentUID, OrgID: cmd.OrgID}) - if err != nil { - return nil, err - } - - // height of the folder that is being moved + this current folder itself + depth of the NewParent folder should be less than or equal MaxNestedFolderDepth - if folderHeight+len(parents)+1 > folder.MaxNestedFolderDepth { - return nil, folder.ErrMaximumDepthReached.Errorf("failed to move folder") - } - - for _, parent := range parents { - // if the current folder is already a parent of newparent, we should return error - if parent.UID == cmd.UID { - return nil, folder.ErrCircularReference.Errorf("failed to move folder") - } - } - - f, err = s.unifiedStore.Update(ctx, folder.UpdateFolderCommand{ + f, err := s.unifiedStore.Update(ctx, folder.UpdateFolderCommand{ UID: cmd.UID, OrgID: cmd.OrgID, NewParentUID: &cmd.NewParentUID, From 3522efdf3223762660b442325043daf902b51db9 Mon Sep 17 00:00:00 2001 From: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:22:26 -0600 Subject: [PATCH 066/229] VizSuggestions: Error handling (#115428) * error handling * retry fetching suggestions * add translation * useAsyncRetry * hasError test * update error handling * clean up the text panel stuff for the current version * cleanup for loop * some more tests for some failure cases * fix lint issue --------- Co-authored-by: Paul Marbach --- .../panel-edit/PanelEditor.tsx | 2 +- .../VisualizationSuggestions.tsx | 194 ++++++++++-------- .../app/features/panel/suggestions/consts.ts | 1 + .../suggestions/getAllSuggestions.test.ts | 113 +++++++--- .../panel/suggestions/getAllSuggestions.ts | 97 +++++---- .../app/features/plugins/importPanelPlugin.ts | 6 + public/app/plugins/panel/table/suggestions.ts | 3 +- public/app/plugins/panel/text/module.tsx | 9 +- public/app/plugins/panel/text/plugin.json | 2 +- public/locales/en-US/grafana.json | 7 +- 10 files changed, 284 insertions(+), 150 deletions(-) diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx index 85813a99f6d..e656a39e6a1 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx @@ -120,7 +120,7 @@ export class PanelEditor extends SceneObjectBase { dataObject.subscribeToState(async () => { const { data } = dataObject.state; if (hasData(data) && panel.state.pluginId === UNCONFIGURED_PANEL_PLUGIN_ID) { - const suggestions = await getAllSuggestions(data); + const { suggestions } = await getAllSuggestions(data); if (suggestions.length > 0) { const defaultFirstSuggestion = suggestions[0]; diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx index e93e74f358d..d1763ad835f 100644 --- a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx +++ b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/css'; import { Fragment, useState, useEffect, useCallback, useMemo } from 'react'; -import { useAsync, useMeasure } from 'react-use'; +import { useAsyncRetry, useMeasure } from 'react-use'; import { GrafanaTheme2, @@ -28,19 +28,23 @@ export interface Props { panel?: PanelModel; } +const useSuggestions = (data: PanelData | undefined) => { + const [hasFetched, setHasFetched] = useState(false); + const { value, loading, error, retry } = useAsyncRetry(async () => { + await new Promise((resolve) => setTimeout(resolve, hasFetched ? 75 : 0)); + setHasFetched(true); + return await getAllSuggestions(data); + }, [hasFetched, data]); + return { value, loading, error, retry }; +}; + export function VisualizationSuggestions({ onChange, data, panel }: Props) { const styles = useStyles2(getStyles); - const { - value: suggestions, - loading, - error, - } = useAsync(async () => { - if (!hasData(data)) { - return []; - } - return await getAllSuggestions(data); - }, [data]); + const { value: result, loading, error, retry } = useSuggestions(data); + + const suggestions = result?.suggestions; + const hasLoadingErrors = result?.hasErrors ?? false; const [suggestionHash, setSuggestionHash] = useState(null); const [firstCardRef, { width }] = useMeasure(); const [firstCardHash, setFirstCardHash] = useState(null); @@ -131,80 +135,97 @@ export function VisualizationSuggestions({ onChange, data, panel }: Props) { } return ( -
- {isNewVizSuggestionsEnabled - ? suggestionsByVizType.map(([vizType, vizTypeSuggestions], groupIndex) => ( - -
- - {vizType?.info && } - {vizType?.name || t('panel.visualization-suggestions.unknown-viz-type', 'Unknown visualization type')} - -
- {vizTypeSuggestions?.map((suggestion, index) => { - const isCardSelected = suggestionHash === suggestion.hash; - return ( -
{ - if (ev.key === 'Enter' || ev.key === ' ') { - ev.preventDefault(); - applySuggestion(suggestion, isNewVizSuggestionsEnabled && !isCardSelected); - } - }} - ref={index === 0 ? firstCardRef : undefined} - > - {isCardSelected && ( - +
+ + )} +
+ {isNewVizSuggestionsEnabled + ? suggestionsByVizType.map(([vizType, vizTypeSuggestions], groupIndex) => ( + +
+ + {vizType?.info && } + {vizType?.name || + t('panel.visualization-suggestions.unknown-viz-type', 'Unknown visualization type')} + +
+ {vizTypeSuggestions?.map((suggestion, index) => { + const isCardSelected = suggestionHash === suggestion.hash; + return ( +
{ + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); + applySuggestion(suggestion, isNewVizSuggestionsEnabled && !isCardSelected); } - > - {t('panel.visualization-suggestions.use-this-suggestion', 'Use this suggestion')} - - )} - applySuggestion(suggestion, true)} - /> -
- ); - })} -
- )) - : suggestions?.map((suggestion, index) => ( -
- applySuggestion(suggestion)} - /> -
- ))} -
+ }} + ref={index === 0 ? firstCardRef : undefined} + > + {isCardSelected && ( + + )} + applySuggestion(suggestion, true)} + /> +
+ ); + })} + + )) + : suggestions?.map((suggestion, index) => ( +
+ applySuggestion(suggestion)} + /> +
+ ))} +
+ ); } @@ -217,6 +238,11 @@ const getStyles = (theme: GrafanaTheme2) => { width: '100%', marginTop: theme.spacing(6), }), + alertContent: css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }), filterRow: css({ display: 'flex', flexDirection: 'row', diff --git a/public/app/features/panel/suggestions/consts.ts b/public/app/features/panel/suggestions/consts.ts index 76e012a7425..decdf8ac7b4 100644 --- a/public/app/features/panel/suggestions/consts.ts +++ b/public/app/features/panel/suggestions/consts.ts @@ -16,4 +16,5 @@ export const panelsToCheckFirst = [ 'heatmap', 'histogram', 'geomap', + 'text', ]; diff --git a/public/app/features/panel/suggestions/getAllSuggestions.test.ts b/public/app/features/panel/suggestions/getAllSuggestions.test.ts index e76a7df15e3..8657824cc27 100644 --- a/public/app/features/panel/suggestions/getAllSuggestions.test.ts +++ b/public/app/features/panel/suggestions/getAllSuggestions.test.ts @@ -1,4 +1,5 @@ import { + AppEvents, DataFrame, FieldType, getDefaultTimeRange, @@ -18,10 +19,20 @@ import { StackingMode, VizOrientation, } from '@grafana/schema'; +import { appEvents } from 'app/core/app_events'; import { config } from 'app/core/config'; +import { clearPanelPluginCache } from 'app/features/plugins/importPanelPlugin'; +import { pluginImporter } from 'app/features/plugins/importer/pluginImporter'; import { panelsToCheckFirst } from './consts'; -import { getAllSuggestions, sortSuggestions } from './getAllSuggestions'; +import { getAllSuggestions, loadPlugins, sortSuggestions } from './getAllSuggestions'; + +jest.mock('app/core/app_events', () => ({ + appEvents: { + subscribe: jest.fn(() => ({ unsubscribe: jest.fn() })), + publish: jest.fn(), + }, +})); config.featureToggles.externalVizSuggestions = true; @@ -52,28 +63,6 @@ for (const pluginId of panelsToCheckFirst) { }; } -config.panels.text = { - id: 'text', - module: 'core:plugin/text', - sort: idx++, - name: 'Text', - type: PluginType.panel, - baseUrl: 'public/app/plugins/panel', - skipDataQuery: true, - suggestions: false, - info: { - version: '1.0.0', - updated: '2025-01-01', - links: [], - screenshots: [], - author: { - name: 'Grafana Labs', - }, - description: 'Text panel', - logos: { small: 'small/logo', large: 'large/logo' }, - }, -}; - jest.mock('../state/util', () => { const originalModule = jest.requireActual('../state/util'); return { @@ -103,7 +92,8 @@ class ScenarioContext { timeRange: getDefaultTimeRange(), }; - this.suggestions = await getAllSuggestions(panelData); + const result = await getAllSuggestions(panelData); + this.suggestions = result.suggestions; } names() { @@ -554,6 +544,81 @@ describe('sortSuggestions', () => { }); }); +describe('Visualization suggestions error handling', () => { + it('returns result with hasErrors flag', async () => { + const result = await getAllSuggestions({ + series: [ + toDataFrame({ + fields: [ + { name: 'Time', type: FieldType.time, values: [1, 2] }, + { name: 'Max', type: FieldType.number, values: [1, 10] }, + ], + }), + ], + state: LoadingState.Done, + timeRange: getDefaultTimeRange(), + }); + + expect(result).toHaveProperty('suggestions'); + expect(result).toHaveProperty('hasErrors'); + expect(result.hasErrors).toBe(false); + }); +}); + +// this needs to happen before any +describe('loadPlugins', () => { + beforeEach(() => { + clearPanelPluginCache(); + }); + + afterEach(() => { + if (jest.isMockFunction(pluginImporter.importPanel)) { + jest.mocked(pluginImporter.importPanel).mockRestore(); + } + }); + + it('should swallow errors when failing to load core plugins', async () => { + jest.spyOn(console, 'error').mockImplementation(); + + const _importPanel = pluginImporter.importPanel; + jest.spyOn(pluginImporter, 'importPanel').mockImplementation(async (meta) => { + if (meta.id === 'timeseries') { + throw new Error('Failed to load core panel plugin'); + } + return await _importPanel(meta); + }); + + const panelIds = ['timeseries', 'table']; + const { plugins, hasErrors } = await loadPlugins(panelIds); + + expect(plugins).toEqual([expect.objectContaining({ meta: expect.objectContaining({ id: 'table' }) })]); + expect(hasErrors).toBe(true); + expect(appEvents.publish).not.toHaveBeenCalled(); + }); + + it('should swallow errors when failing to load external plugins', async () => { + jest.spyOn(console, 'error').mockImplementation(); + + const panelIds = ['non-existent-panel']; + const { plugins, hasErrors } = await loadPlugins(panelIds); + + expect(plugins).toEqual([]); + expect(hasErrors).toBe(false); + expect(appEvents.publish).toHaveBeenCalledWith({ + type: AppEvents.alertError.name, + payload: [expect.stringContaining('Failed to load panel plugin: non-existent-panel.')], + }); + }); + + it('should load panel plugins with suggestions', async () => { + const panelIds = ['timeseries', 'table']; + const { plugins, hasErrors } = await loadPlugins(panelIds); + + expect(plugins.map((p) => p.meta.id)).toEqual(expect.arrayContaining(['timeseries', 'table'])); + expect(hasErrors).toBe(false); + }); +}); + function repeatFrame(count: number, frame: DataFrame): DataFrame[] { const frames: DataFrame[] = []; for (let i = 0; i < count; i++) { diff --git a/public/app/features/panel/suggestions/getAllSuggestions.ts b/public/app/features/panel/suggestions/getAllSuggestions.ts index ea94add39ab..d0bb73dfb83 100644 --- a/public/app/features/panel/suggestions/getAllSuggestions.ts +++ b/public/app/features/panel/suggestions/getAllSuggestions.ts @@ -1,4 +1,5 @@ import { + AppEvents, getPanelDataSummary, PanelData, PanelDataSummary, @@ -7,41 +8,67 @@ import { PreferredVisualisationType, VisualizationSuggestionScore, } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; +import { appEvents } from 'app/core/app_events'; import { importPanelPlugin, isBuiltInPlugin } from 'app/features/plugins/importPanelPlugin'; import { getAllPanelPluginMeta } from '../state/util'; import { panelsToCheckFirst } from './consts'; -/** - * gather and cache the plugins which provide visualization suggestions so they can be invoked to build suggestions - */ -async function getPanelsWithSuggestions(): Promise { - // list of plugins to load is determined by the feature flag - const pluginIds: string[] = config.featureToggles.externalVizSuggestions +interface PluginLoadResult { + plugins: PanelPlugin[]; + hasErrors: boolean; +} + +function getPanelPluginIds(): string[] { + return config.featureToggles.externalVizSuggestions ? getAllPanelPluginMeta() .filter((panel) => panel.suggestions) .map((m) => m.id) : panelsToCheckFirst; +} +/** + * gather and cache the plugins which provide visualization suggestions so they can be invoked to build suggestions + */ +export async function loadPlugins(pluginIds: string[]): Promise { // import the plugins in parallel using Promise.allSettled const plugins: PanelPlugin[] = []; - const settledPromises = await Promise.allSettled(pluginIds.map((id) => importPanelPlugin(id))); + let hasErrors = false; + const settledPromises = await Promise.allSettled( + pluginIds.map(async (pluginId) => { + return await importPanelPlugin(pluginId); + }) + ); + for (let i = 0; i < settledPromises.length; i++) { const settled = settledPromises[i]; - if (settled.status === 'fulfilled') { plugins.push(settled.value); + } else { + const pluginId = pluginIds[i]; + console.error(`Failed to load ${pluginId} for visualization suggestions:`, settled.reason); + + if (isBuiltInPlugin(pluginId)) { + hasErrors = true; + } else { + appEvents.publish({ + type: AppEvents.alertError.name, + payload: [ + t( + 'panel.visualization-suggestions.error-loading-suggestions.plugin-failed', + 'Failed to load panel plugin: {{ pluginId }}.', + { pluginId } + ), + ], + }); + } } - // TODO: do we want to somehow log if there were errors loading some of the plugins? } - if (plugins.length === 0) { - throw new Error('No panel plugins with visualization suggestions found'); - } - - return plugins; + return { plugins, hasErrors }; } /** @@ -89,41 +116,37 @@ export function sortSuggestions(suggestions: PanelPluginVisualizationSuggestion[ }); } +export interface SuggestionsResult { + suggestions: PanelPluginVisualizationSuggestion[]; + hasErrors: boolean; +} + /** * given PanelData, return a sorted list of Suggestions from all plugins which support it. * @param {PanelData} data queried and transformed data for the panel - * @returns {PanelPluginVisualizationSuggestion[]} sorted list of suggestions + * @returns {SuggestionsResult} sorted list of suggestions and error status */ -export async function getAllSuggestions(data?: PanelData): Promise { +export async function getAllSuggestions(data?: PanelData): Promise { const dataSummary = getPanelDataSummary(data?.series); const list: PanelPluginVisualizationSuggestion[] = []; - for (const plugin of await getPanelsWithSuggestions()) { - const suggestions = plugin.getSuggestions(dataSummary); - if (suggestions) { - list.push(...suggestions); - } - } + const pluginIds: string[] = getPanelPluginIds(); + const { plugins, hasErrors: pluginLoadErrors } = await loadPlugins(pluginIds); - if (dataSummary.fieldCount === 0) { - for (const plugin of Object.values(config.panels)) { - if (!plugin.skipDataQuery || plugin.hideFromList) { - continue; + let pluginSuggestionsError = false; + for (const plugin of plugins) { + try { + const suggestions = plugin.getSuggestions(dataSummary); + if (suggestions) { + list.push(...suggestions); } - - list.push({ - name: plugin.name, - pluginId: plugin.id, - description: plugin.info.description, - hash: 'plugin-empty-' + plugin.id, - cardOptions: { - imgSrc: plugin.info.logos.small, - }, - }); + } catch (e) { + console.warn(`error when loading suggestions from plugin "${plugin.meta.id}"`, e); + pluginSuggestionsError = true; } } sortSuggestions(list, dataSummary); - return list; + return { suggestions: list, hasErrors: pluginLoadErrors || pluginSuggestionsError }; } diff --git a/public/app/features/plugins/importPanelPlugin.ts b/public/app/features/plugins/importPanelPlugin.ts index e541b8f7893..ef8d955f562 100644 --- a/public/app/features/plugins/importPanelPlugin.ts +++ b/public/app/features/plugins/importPanelPlugin.ts @@ -62,3 +62,9 @@ export function syncGetPanelPlugin(id: string): PanelPlugin | undefined { function getPanelPlugin(meta: PanelPluginMeta): Promise { return pluginImporter.importPanel(meta); } + +export function clearPanelPluginCache(): void { + for (const key of Object.keys(promiseCache)) { + delete promiseCache[key]; + } +} diff --git a/public/app/plugins/panel/table/suggestions.ts b/public/app/plugins/panel/table/suggestions.ts index 8e0d6e44953..260e73b43eb 100644 --- a/public/app/plugins/panel/table/suggestions.ts +++ b/public/app/plugins/panel/table/suggestions.ts @@ -1,4 +1,5 @@ import { PanelDataSummary, VisualizationSuggestionScore, VisualizationSuggestionsSupplier } from '@grafana/data'; +import { config } from 'app/core/config'; import icnTablePanelSvg from 'app/plugins/panel/table/img/icn-table-panel.svg'; import { Options, FieldConfig } from './panelcfg.gen'; @@ -29,7 +30,7 @@ export const tableSuggestionsSupplier: VisualizationSuggestionsSupplier(TextPanel) defaultValue: defaultOptions.content, }); }) - .setMigrationHandler(textPanelMigrationHandler); + .setMigrationHandler(textPanelMigrationHandler) + .setSuggestionsSupplier((ds) => + ds.fieldCount === 0 && !config.featureToggles.newVizSuggestions + ? [{ cardOptions: { imgSrc: icnTextPanelSvg } }] + : [] + ); diff --git a/public/app/plugins/panel/text/plugin.json b/public/app/plugins/panel/text/plugin.json index ce437e97bf5..a1acce42c6e 100644 --- a/public/app/plugins/panel/text/plugin.json +++ b/public/app/plugins/panel/text/plugin.json @@ -2,7 +2,7 @@ "type": "panel", "name": "Text", "id": "text", - + "suggestions": true, "skipDataQuery": true, "info": { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 9678b91e619..a4e39d534a3 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -11216,9 +11216,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "Apply {{suggestionName}} visualization", + "error-loading-some-suggestions": { + "message": "Some suggestions could not be loaded" + }, "error-loading-suggestions": { "message": "An error occurred when loading visualization suggestions.", - "title": "Error" + "plugin-failed": "Failed to load panel plugin: {{ pluginId }}.", + "title": "Error", + "try-again-button": "Try again" }, "unknown-viz-type": "Unknown visualization type", "use-this-suggestion": "Use this suggestion" From 0284d1e669cee4569e1cc33c11467a122da6955d Mon Sep 17 00:00:00 2001 From: Renato Costa <103441181+renatolabs@users.noreply.github.com> Date: Fri, 19 Dec 2025 15:35:22 -0500 Subject: [PATCH 067/229] unified-storage: add `UnixTimestamp` support to the sqlkv implementation (#115651) * unified-storage: add `UnixTimestamp` support to sqlkv implementation * unified-storage: improve tests and enable all of them on sqlkv --- pkg/storage/unified/resource/sqlkv.go | 3 +- pkg/storage/unified/testing/kv.go | 264 +++++++++++++------------ pkg/storage/unified/testing/kv_test.go | 8 +- 3 files changed, 143 insertions(+), 132 deletions(-) diff --git a/pkg/storage/unified/resource/sqlkv.go b/pkg/storage/unified/resource/sqlkv.go index 9cc2cc32dd0..6d406294a96 100644 --- a/pkg/storage/unified/resource/sqlkv.go +++ b/pkg/storage/unified/resource/sqlkv.go @@ -11,6 +11,7 @@ import ( "iter" "strings" "text/template" + "time" "github.com/google/uuid" "github.com/grafana/grafana/pkg/storage/unified/sql/db" @@ -556,7 +557,7 @@ func (k *sqlKV) BatchDelete(ctx context.Context, section string, keys []string) } func (k *sqlKV) UnixTimestamp(ctx context.Context) (int64, error) { - panic("not implemented!") + return time.Now().Unix(), nil } func closeRows[T any](rows db.Rows, yield func(T, error) bool) { diff --git a/pkg/storage/unified/testing/kv.go b/pkg/storage/unified/testing/kv.go index d1900c7a46e..2031a3e38b2 100644 --- a/pkg/storage/unified/testing/kv.go +++ b/pkg/storage/unified/testing/kv.go @@ -148,13 +148,15 @@ func runTestKVGet(t *testing.T, kv resource.KV, nsPrefix string) { func runTestKVSave(t *testing.T, kv resource.KV, nsPrefix string) { ctx := testutil.NewTestContext(t, time.Now().Add(30*time.Second)) + nsPrefix += "-save" t.Run("save new key", func(t *testing.T) { + newKey := namespacedKey(nsPrefix, "new-key") testValue := "new test value" - saveKVHelper(t, kv, ctx, testSection, "new-key", strings.NewReader(testValue)) + saveKVHelper(t, kv, ctx, testSection, newKey, strings.NewReader(testValue)) // Verify it was saved - reader, err := kv.Get(ctx, testSection, "new-key") + reader, err := kv.Get(ctx, testSection, newKey) require.NoError(t, err) value, err := io.ReadAll(reader) @@ -165,15 +167,17 @@ func runTestKVSave(t *testing.T, kv resource.KV, nsPrefix string) { }) t.Run("save overwrite existing key", func(t *testing.T) { + overwriteKey := namespacedKey(nsPrefix, "overwrite-key") + // First save - saveKVHelper(t, kv, ctx, testSection, "overwrite-key", strings.NewReader("old value")) + saveKVHelper(t, kv, ctx, testSection, overwriteKey, strings.NewReader("old value")) // Overwrite newValue := "new value" - saveKVHelper(t, kv, ctx, testSection, "overwrite-key", strings.NewReader(newValue)) + saveKVHelper(t, kv, ctx, testSection, overwriteKey, strings.NewReader(newValue)) // Verify it was updated - reader, err := kv.Get(ctx, testSection, "overwrite-key") + reader, err := kv.Get(ctx, testSection, overwriteKey) require.NoError(t, err) value, err := io.ReadAll(reader) @@ -185,15 +189,17 @@ func runTestKVSave(t *testing.T, kv resource.KV, nsPrefix string) { t.Run("save overwrite existing key (datastore)", func(t *testing.T) { section := "unified/data" + overwriteKey := namespacedKey(nsPrefix, "overwrite-key") + // First save - saveKVHelper(t, kv, ctx, section, "overwrite-key", strings.NewReader("old value")) + saveKVHelper(t, kv, ctx, section, overwriteKey, strings.NewReader("old value")) // Overwrite newValue := "new value" - saveKVHelper(t, kv, ctx, section, "overwrite-key", strings.NewReader(newValue)) + saveKVHelper(t, kv, ctx, section, overwriteKey, strings.NewReader(newValue)) // Verify it was updated - reader, err := kv.Get(ctx, section, "overwrite-key") + reader, err := kv.Get(ctx, section, overwriteKey) require.NoError(t, err) value, err := io.ReadAll(reader) @@ -210,11 +216,13 @@ func runTestKVSave(t *testing.T, kv resource.KV, nsPrefix string) { }) t.Run("save binary data", func(t *testing.T) { + binaryKey := namespacedKey(nsPrefix, "binary-key") + binaryData := []byte{0x00, 0x01, 0x02, 0x03, 0xFF, 0xFE, 0xFD} - saveKVHelper(t, kv, ctx, testSection, "binary-key", bytes.NewReader(binaryData)) + saveKVHelper(t, kv, ctx, testSection, binaryKey, bytes.NewReader(binaryData)) // Verify binary data - reader, err := kv.Get(ctx, testSection, "binary-key") + reader, err := kv.Get(ctx, testSection, binaryKey) require.NoError(t, err) value, err := io.ReadAll(reader) @@ -225,11 +233,13 @@ func runTestKVSave(t *testing.T, kv resource.KV, nsPrefix string) { }) t.Run("save key with no data", func(t *testing.T) { + emptyKey := namespacedKey(nsPrefix, "empty-key") + // Save a key with empty data - saveKVHelper(t, kv, ctx, testSection, "empty-key", strings.NewReader("")) + saveKVHelper(t, kv, ctx, testSection, emptyKey, strings.NewReader("")) // Verify it was saved with empty data - reader, err := kv.Get(ctx, testSection, "empty-key") + reader, err := kv.Get(ctx, testSection, emptyKey) require.NoError(t, err) value, err := io.ReadAll(reader) @@ -495,128 +505,134 @@ func runTestKVKeysWithSort(t *testing.T, kv resource.KV, nsPrefix string) { func runTestKVConcurrent(t *testing.T, kv resource.KV, nsPrefix string) { ctx := testutil.NewTestContext(t, time.Now().Add(60*time.Second)) - section := nsPrefix + "-concurrent" + nsPrefix += "-concurrent" - t.Run("concurrent save and get operations", func(t *testing.T) { - const numGoroutines = 10 - const numOperations = 20 + // Test concurrent operations for both sections, as they have different behaviours + // in the sqlkv implementation. + for _, testSection := range []string{"unified/data", "unified/events"} { + t.Run(testSection, func(t *testing.T) { + t.Run("concurrent save and get operations", func(t *testing.T) { + const numGoroutines = 10 + const numOperations = 20 - done := make(chan error, numGoroutines) + done := make(chan error, numGoroutines) - for i := 0; i < numGoroutines; i++ { - go func(goroutineID int) { - var err error - defer func() { done <- err }() + for goroutineID := range numGoroutines { + go func() { + var err error + defer func() { done <- err }() - for j := 0; j < numOperations; j++ { - key := fmt.Sprintf("concurrent-key-%d-%d", goroutineID, j) - value := fmt.Sprintf("concurrent-value-%d-%d", goroutineID, j) + for j := range numOperations { + key := namespacedKey(nsPrefix, fmt.Sprintf("concurrent-key-%d-%d", goroutineID, j)) + value := fmt.Sprintf("concurrent-value-%d-%d", goroutineID, j) - // Save - writer, err := kv.Save(ctx, section, key) - if err != nil { - return - } - defer func() { - err := writer.Close() - require.NoError(t, err) + // Save + writer, err := kv.Save(ctx, testSection, key) + if err != nil { + return + } + defer func() { + err := writer.Close() + require.NoError(t, err) + }() + _, err = io.Copy(writer, strings.NewReader(value)) + if err != nil { + return + } + err = writer.Close() + if err != nil { + return + } + + // Get immediately + reader, err := kv.Get(ctx, testSection, key) + if err != nil { + return + } + + readValue, err := io.ReadAll(reader) + require.NoError(t, err) + err = reader.Close() + require.NoError(t, err) + assert.Equal(t, value, string(readValue)) + } }() - _, err = io.Copy(writer, strings.NewReader(value)) - if err != nil { - return - } - err = writer.Close() - if err != nil { - return - } + } - // Get immediately - reader, err := kv.Get(ctx, section, key) - if err != nil { - return - } - - readValue, err := io.ReadAll(reader) + // Wait for all goroutines to complete + for range numGoroutines { + err := <-done require.NoError(t, err) - err = reader.Close() + } + }) + + t.Run("concurrent save, delete, and list operations", func(t *testing.T) { + const numGoroutines = 5 + done := make(chan error, numGoroutines) + + for i := range numGoroutines { + go func(goroutineID int) { + var err error + defer func() { done <- err }() + + key := namespacedKey(nsPrefix, fmt.Sprintf("concurrent-ops-key-%d", goroutineID)) + value := fmt.Sprintf("concurrent-ops-value-%d", goroutineID) + + // Save + writer, err := kv.Save(ctx, testSection, key) + if err != nil { + return + } + defer func() { + err := writer.Close() + require.NoError(t, err) + }() + _, err = io.Copy(writer, strings.NewReader(value)) + if err != nil { + return + } + err = writer.Close() + if err != nil { + return + } + + // List to verify it exists + found := false + for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{}) { + if err != nil { + return + } + if k == key { + found = true + break + } + } + if !found { + err = fmt.Errorf("key %s not found in list", key) + return + } + + // Delete + err = kv.Delete(ctx, testSection, key) + if err != nil { + return + } + + // Verify it's deleted + _, err = kv.Get(ctx, testSection, key) + require.ErrorIs(t, resource.ErrNotFound, err) + err = nil // Expected error, so clear it + }(i) + } + + // Wait for all goroutines to complete + for range numGoroutines { + err := <-done require.NoError(t, err) - assert.Equal(t, value, string(readValue)) } - }(i) - } - - // Wait for all goroutines to complete - for i := 0; i < numGoroutines; i++ { - err := <-done - require.NoError(t, err) - } - }) - - t.Run("concurrent save, delete, and list operations", func(t *testing.T) { - const numGoroutines = 5 - done := make(chan error, numGoroutines) - - for i := 0; i < numGoroutines; i++ { - go func(goroutineID int) { - var err error - defer func() { done <- err }() - - key := fmt.Sprintf("concurrent-ops-key-%d", goroutineID) - value := fmt.Sprintf("concurrent-ops-value-%d", goroutineID) - - // Save - writer, err := kv.Save(ctx, section, key) - if err != nil { - return - } - defer func() { - err := writer.Close() - require.NoError(t, err) - }() - _, err = io.Copy(writer, strings.NewReader(value)) - if err != nil { - return - } - err = writer.Close() - if err != nil { - return - } - - // List to verify it exists - found := false - for k, err := range kv.Keys(ctx, section, resource.ListOptions{}) { - if err != nil { - return - } - if k == key { - found = true - break - } - } - if !found { - err = fmt.Errorf("key %s not found in list", key) - return - } - - // Delete - err = kv.Delete(ctx, section, key) - if err != nil { - return - } - - // Verify it's deleted - _, err = kv.Get(ctx, section, key) - require.ErrorIs(t, resource.ErrNotFound, err) - err = nil // Expected error, so clear it - }(i) - } - - // Wait for all goroutines to complete - for i := 0; i < numGoroutines; i++ { - err := <-done - require.NoError(t, err) - } - }) + }) + }) + } } func runTestKVUnixTimestamp(t *testing.T, kv resource.KV, nsPrefix string) { diff --git a/pkg/storage/unified/testing/kv_test.go b/pkg/storage/unified/testing/kv_test.go index 5e94ccd8a7f..af7de65e52c 100644 --- a/pkg/storage/unified/testing/kv_test.go +++ b/pkg/storage/unified/testing/kv_test.go @@ -44,11 +44,5 @@ func TestSQLKV(t *testing.T) { kv, err := resource.NewSQLKV(eDB) require.NoError(t, err) return kv - }, &KVTestOptions{ - NSPrefix: "sql-kv-test", - SkipTests: map[string]bool{ - TestKVConcurrent: true, - TestKVUnixTimestamp: true, - }, - }) + }, &KVTestOptions{NSPrefix: "sql-kv-test"}) } From 8cfac85b48145f485b2caac2cac2721e352717e9 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Fri, 19 Dec 2025 15:41:57 -0500 Subject: [PATCH 068/229] Gauge: Add guide dots for rounded bars to help with accuracy, update color logic for more consistent gradients (#115285) * Gauge: Fit-and-finish tweaks to glows, text position, and sparkline size * adjust text height and positions a little more * cohesive no data handling * more tweaks * fix migration test * Fix JSON formatting by adding missing newline * remove new line * Gauge: Add guide dots for rounded bars to help with accuracy * 30% width * remove spotlight, starting to make gradients a bit more predictable * fix segmented * update rotation of gauge color * update i18n and migration tests * fix spacing * more fixture updates * wip: using clip-path and CSS for drawing the gauge * wip: overhaul color in gauge * wip: progress on everything * refactoring defs into utils * its all working * fixme comment * fix backend migration tests * remove any other mentions of spotlights * one more tweak * update gdev * add lots of tests and reorganize the code a bit * fix dev dashboard fixture * more cleanup, optimization * fix a couple of bugs * fix bad import * disable storybook test due to false positive * a more sweeping disable of the color-contrast * update backend tests * update gradient for fixed color * test all dark/light theme variants * set opacity to 0.5 for dots * move min degrees for start dot render to a const * change endpoint marks to be configurable * update gdev and fixtures * i18n * shore up testing a bit * remove period for consistency * hide glow at small angles * more testing and cleanup * addressing PR comments * Update packages/grafana-ui/src/components/RadialGauge/colors.ts Co-authored-by: Jesse David Peterson * Update packages/grafana-ui/src/components/RadialGauge/colors.ts Co-authored-by: Jesse David Peterson * break out binary search stuff and write tests * fix lint issues --------- Co-authored-by: Jesse David Peterson --- .../v0alpha1.gauge_tests_new.v42.json | 171 ++------- .../v0alpha1.gauge_tests_old_to_new.v42.json | 2 - .../v0alpha1.gauge_tests_new.v42.v1beta1.json | 221 +++--------- ...v0alpha1.gauge_tests_new.v42.v2alpha1.json | 247 +++---------- .../v0alpha1.gauge_tests_new.v42.v2beta1.json | 250 +++---------- ...a1.gauge_tests_old_to_new.v42.v1beta1.json | 6 +- ...1.gauge_tests_old_to_new.v42.v2alpha1.json | 6 +- ...a1.gauge_tests_old_to_new.v42.v2beta1.json | 6 +- .../panel-gauge/gauge_tests_new.v42.json | 336 ++++++------------ .../gauge_tests_old_to_new.v42.json | 6 +- .../panel-gauge/gauge_tests_new.json | 335 ++++++----------- .../panel-gauge/gauge_tests_old_to_new.json | 2 - .../panelcfg/x/NewGaugePanelCfg_types.gen.ts | 8 +- .../components/RadialGauge/RadialArcPath.tsx | 185 +++++++--- .../src/components/RadialGauge/RadialBar.tsx | 119 +++---- .../RadialGauge/RadialBarSegmented.tsx | 164 +++------ .../RadialGauge/RadialColorDefs.tsx | 141 -------- .../RadialGauge/RadialGauge.story.tsx | 128 +++---- .../RadialGauge/RadialGauge.test.tsx | 25 +- .../components/RadialGauge/RadialGauge.tsx | 69 ++-- .../RadialGauge/RadialScaleLabels.tsx | 119 +++---- .../RadialGauge/RadialSparkline.tsx | 99 ++++-- .../src/components/RadialGauge/RadialText.tsx | 222 ++++++------ .../components/RadialGauge/ThresholdsBar.tsx | 42 +-- .../__snapshots__/colors.test.ts.snap | 144 ++++++++ .../__snapshots__/utils.test.ts.snap | 17 + .../src/components/RadialGauge/colors.test.ts | 306 ++++++++++++++++ .../src/components/RadialGauge/colors.ts | 195 ++++++++++ .../src/components/RadialGauge/effects.tsx | 89 +++-- .../src/components/RadialGauge/types.ts | 25 ++ .../src/components/RadialGauge/utils.test.ts | 197 +++++++++- .../src/components/RadialGauge/utils.ts | 171 +++++++-- .../plugins/panel/radialbar/EffectsEditor.tsx | 11 - .../panel/radialbar/RadialBarPanel.tsx | 6 +- public/app/plugins/panel/radialbar/module.tsx | 30 ++ .../app/plugins/panel/radialbar/panelcfg.cue | 6 +- .../plugins/panel/radialbar/panelcfg.gen.ts | 8 +- .../plugins/panel/radialbar/suggestions.ts | 13 - public/locales/en-US/grafana.json | 19 +- 39 files changed, 2142 insertions(+), 2004 deletions(-) delete mode 100644 packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx create mode 100644 packages/grafana-ui/src/components/RadialGauge/__snapshots__/colors.test.ts.snap create mode 100644 packages/grafana-ui/src/components/RadialGauge/__snapshots__/utils.test.ts.snap create mode 100644 packages/grafana-ui/src/components/RadialGauge/colors.test.ts create mode 100644 packages/grafana-ui/src/components/RadialGauge/colors.ts create mode 100644 packages/grafana-ui/src/components/RadialGauge/types.ts diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.json index 7c7c479199d..62648d7a4aa 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.json @@ -71,12 +71,11 @@ "id": 1, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": false, - "rounded": true, - "spotlight": false, "gradient": false }, "orientation": "auto", @@ -150,12 +149,11 @@ "id": 4, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": true, - "rounded": true, - "spotlight": false, "gradient": false }, "orientation": "auto", @@ -229,12 +227,11 @@ "id": 3, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": false, "gradient": false }, "orientation": "auto", @@ -271,85 +268,6 @@ "title": "Center and bar glow", "type": "radialbar" }, - { - "datasource": { - "type": "grafana-testdata-datasource" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 4, - "x": 12, - "y": 1 - }, - "id": 5, - "maxDataPoints": 20, - "options": { - "barWidthFactor": 0.4, - "effects": { - "barGlow": true, - "centerGlow": true, - "rounded": true, - "spotlight": true, - "gradient": false - }, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "segmentCount": 1, - "segmentSpacing": 0.3, - "shape": "circle", - "showThresholdLabels": false, - "showThresholdMarkers": false, - "sparkline": false - }, - "pluginVersion": "13.0.0-pre", - "targets": [ - { - "alias": "1", - "datasource": { - "type": "grafana-testdata-datasource" - }, - "max": 100, - "min": 1, - "noise": 22, - "refId": "A", - "scenarioId": "random_walk", - "spread": 22, - "startValue": 1 - } - ], - "title": "Spotlight", - "type": "radialbar" - }, { "datasource": { "type": "grafana-testdata-datasource" @@ -391,10 +309,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -470,10 +387,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": false, - "spotlight": true, "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -549,10 +465,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": false, - "spotlight": true, "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -641,10 +556,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -720,10 +634,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -799,10 +712,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -878,10 +790,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -974,10 +885,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1053,10 +963,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1132,10 +1041,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1211,10 +1119,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1290,10 +1197,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1386,10 +1292,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1469,10 +1374,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1552,10 +1456,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1641,13 +1544,13 @@ "options": { "barWidth": 12, "barWidthFactor": 0.4, + "barShape": "rounded", "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": true }, + "endpointMarker": "glow", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1662,8 +1565,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1730,10 +1632,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": true }, + "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1748,8 +1649,7 @@ "shape": "gauge", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": true, - "spotlight": true + "sparkline": true }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1830,10 +1730,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": true }, + "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1848,8 +1747,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1917,9 +1815,6 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "sparkline": false, - "spotlight": true, "gradient": true }, "glow": "both", @@ -1934,10 +1829,10 @@ "segmentCount": 12, "segmentSpacing": 0.3, "shape": "circle", + "barShape": "rounded", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -2004,10 +1899,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": true }, + "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -2022,8 +1916,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -2090,10 +1983,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": true }, + "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -2108,8 +2000,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.json index a3de6df336a..dda0fbcd432 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.json @@ -955,8 +955,6 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": false }, "orientation": "auto", diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v1beta1.json index 2eeb3040e6d..e04d448a5b8 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v1beta1.json @@ -77,13 +77,12 @@ "id": 1, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -156,13 +155,12 @@ "id": 4, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -235,13 +233,12 @@ "id": 3, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -277,85 +274,6 @@ "title": "Center and bar glow", "type": "radialbar" }, - { - "datasource": { - "type": "grafana-testdata-datasource" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 4, - "x": 12, - "y": 1 - }, - "id": 5, - "maxDataPoints": 20, - "options": { - "barWidthFactor": 0.4, - "effects": { - "barGlow": true, - "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true - }, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "segmentCount": 1, - "segmentSpacing": 0.3, - "shape": "circle", - "showThresholdLabels": false, - "showThresholdMarkers": false, - "sparkline": false - }, - "pluginVersion": "13.0.0-pre", - "targets": [ - { - "alias": "1", - "datasource": { - "type": "grafana-testdata-datasource" - }, - "max": 100, - "min": 1, - "noise": 22, - "refId": "A", - "scenarioId": "random_walk", - "spread": 22, - "startValue": 1 - } - ], - "title": "Spotlight", - "type": "radialbar" - }, { "datasource": { "type": "grafana-testdata-datasource" @@ -393,13 +311,12 @@ "id": 8, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -472,13 +389,12 @@ "id": 22, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -551,13 +467,12 @@ "id": 23, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -643,13 +558,12 @@ "id": 18, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.1, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -722,13 +636,12 @@ "id": 19, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.32, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -801,13 +714,12 @@ "id": 20, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.57, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -880,13 +792,12 @@ "id": 21, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.8, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -976,13 +887,12 @@ "id": 25, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1055,13 +965,12 @@ "id": 26, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1134,13 +1043,12 @@ "id": 29, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1213,13 +1121,12 @@ "id": 30, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1292,13 +1199,12 @@ "id": 28, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1388,13 +1294,12 @@ "id": 32, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1471,13 +1376,12 @@ "id": 34, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1554,13 +1458,12 @@ "id": 33, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1645,15 +1548,15 @@ "id": 9, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "endpointMarker": "glow", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1668,8 +1571,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1731,14 +1633,13 @@ "id": 11, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -1754,8 +1655,7 @@ "shape": "gauge", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": true, - "spotlight": true + "sparkline": true }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1831,14 +1731,13 @@ "id": 13, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.49, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -1854,8 +1753,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1918,15 +1816,13 @@ "id": 14, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.49, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "sparkline": false, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -1942,8 +1838,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -2005,14 +1900,13 @@ "id": 15, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.84, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -2028,8 +1922,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -2091,14 +1984,13 @@ "id": 16, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.66, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -2114,8 +2006,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -2160,4 +2051,4 @@ "storedVersion": "v0alpha1" } } -} \ No newline at end of file +} diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2alpha1.json index 4aecf4e0d9c..0e6e3e13da5 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2alpha1.json @@ -73,13 +73,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -165,14 +164,13 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -188,8 +186,7 @@ "shape": "gauge", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": true, - "spotlight": true + "sparkline": true }, "fieldConfig": { "defaults": { @@ -262,14 +259,13 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.49, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -285,8 +281,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -360,15 +355,13 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.49, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "sparkline": false, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -384,8 +377,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -459,14 +451,13 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.84, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -482,8 +473,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -556,14 +546,13 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.66, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -579,8 +568,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -653,13 +641,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidthFactor": 0.1, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -745,13 +732,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidthFactor": 0.32, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -837,13 +823,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidthFactor": 0.57, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -929,13 +914,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidthFactor": 0.8, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1021,13 +1005,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1113,13 +1096,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1201,13 +1183,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1293,13 +1274,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1385,13 +1365,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1477,13 +1456,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1573,13 +1551,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1661,13 +1638,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1753,13 +1729,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1849,13 +1824,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1945,13 +1919,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -2045,105 +2018,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false - }, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "segmentCount": 1, - "segmentSpacing": 0.3, - "shape": "circle", - "showThresholdLabels": false, - "showThresholdMarkers": false, - "sparkline": false - }, - "fieldConfig": { - "defaults": { - "min": 0, - "max": 100, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "value": 0, - "color": "green" - }, - { - "value": 80, - "color": "red" - } - ] - }, - "color": { - "mode": "thresholds" - } - }, - "overrides": [] - } - } - } - } - }, - "panel-5": { - "kind": "Panel", - "spec": { - "id": 5, - "title": "Spotlight", - "description": "", - "links": [], - "data": { - "kind": "QueryGroup", - "spec": { - "queries": [ - { - "kind": "PanelQuery", - "spec": { - "query": { - "kind": "grafana-testdata-datasource", - "spec": { - "alias": "1", - "max": 100, - "min": 1, - "noise": 22, - "scenarioId": "random_walk", - "spread": 22, - "startValue": 1 - } - }, - "refId": "A", - "hidden": false - } - } - ], - "transformations": [], - "queryOptions": { - "maxDataPoints": 20 - } - } - }, - "vizConfig": { - "kind": "radialbar", - "spec": { - "pluginVersion": "13.0.0-pre", - "options": { - "barWidthFactor": 0.4, - "effects": { - "barGlow": true, - "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -2229,13 +2109,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -2321,15 +2200,15 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "endpointMarker": "glow", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -2344,8 +2223,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -2429,19 +2307,6 @@ } } }, - { - "kind": "GridLayoutItem", - "spec": { - "x": 12, - "y": 0, - "width": 4, - "height": 6, - "element": { - "kind": "ElementReference", - "name": "panel-5" - } - } - }, { "kind": "GridLayoutItem", "spec": { @@ -2826,4 +2691,4 @@ "storedVersion": "v0alpha1" } } -} \ No newline at end of file +} diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2beta1.json index 6b567f19b5e..ad2b8ca0385 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2beta1.json @@ -77,13 +77,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -172,14 +171,13 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -195,8 +193,7 @@ "shape": "gauge", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": true, - "spotlight": true + "sparkline": true }, "fieldConfig": { "defaults": { @@ -272,14 +269,13 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.49, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -295,8 +291,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -373,15 +368,13 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.49, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "sparkline": false, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -397,8 +390,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -475,14 +467,13 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.84, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -498,8 +489,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -575,14 +565,13 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.66, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -598,8 +587,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -675,13 +663,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidthFactor": 0.1, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -770,13 +757,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidthFactor": 0.32, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -865,13 +851,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidthFactor": 0.57, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -960,13 +945,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidthFactor": 0.8, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1055,13 +1039,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1150,13 +1133,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1241,13 +1223,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1336,13 +1317,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1431,13 +1411,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1526,13 +1505,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1625,13 +1603,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1716,13 +1693,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1811,13 +1787,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1910,13 +1885,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -2009,13 +1983,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -2112,108 +2085,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false - }, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "segmentCount": 1, - "segmentSpacing": 0.3, - "shape": "circle", - "showThresholdLabels": false, - "showThresholdMarkers": false, - "sparkline": false - }, - "fieldConfig": { - "defaults": { - "min": 0, - "max": 100, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "value": 0, - "color": "green" - }, - { - "value": 80, - "color": "red" - } - ] - }, - "color": { - "mode": "thresholds" - } - }, - "overrides": [] - } - } - } - } - }, - "panel-5": { - "kind": "Panel", - "spec": { - "id": 5, - "title": "Spotlight", - "description": "", - "links": [], - "data": { - "kind": "QueryGroup", - "spec": { - "queries": [ - { - "kind": "PanelQuery", - "spec": { - "query": { - "kind": "DataQuery", - "group": "grafana-testdata-datasource", - "version": "v0", - "spec": { - "alias": "1", - "max": 100, - "min": 1, - "noise": 22, - "scenarioId": "random_walk", - "spread": 22, - "startValue": 1 - } - }, - "refId": "A", - "hidden": false - } - } - ], - "transformations": [], - "queryOptions": { - "maxDataPoints": 20 - } - } - }, - "vizConfig": { - "kind": "VizConfig", - "group": "radialbar", - "version": "13.0.0-pre", - "spec": { - "options": { - "barWidthFactor": 0.4, - "effects": { - "barGlow": true, - "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -2302,13 +2179,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -2397,15 +2273,15 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "endpointMarker": "glow", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -2420,8 +2296,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -2505,19 +2380,6 @@ } } }, - { - "kind": "GridLayoutItem", - "spec": { - "x": 12, - "y": 0, - "width": 4, - "height": 6, - "element": { - "kind": "ElementReference", - "name": "panel-5" - } - } - }, { "kind": "GridLayoutItem", "spec": { @@ -2902,4 +2764,4 @@ "storedVersion": "v0alpha1" } } -} \ No newline at end of file +} diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v1beta1.json index 959f0193ad6..1d9f7e56513 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v1beta1.json @@ -961,9 +961,7 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1175,4 +1173,4 @@ "storedVersion": "v0alpha1" } } -} \ No newline at end of file +} diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2alpha1.json index 66b29e88d13..7b3f601b5cf 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2alpha1.json @@ -864,9 +864,7 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1620,4 +1618,4 @@ "storedVersion": "v0alpha1" } } -} \ No newline at end of file +} diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2beta1.json index b870d0a91ad..534e7a1600c 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2beta1.json @@ -901,9 +901,7 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1672,4 +1670,4 @@ "storedVersion": "v0alpha1" } } -} \ No newline at end of file +} diff --git a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json index a89d8744f39..cb130445efc 100644 --- a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json +++ b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json @@ -75,10 +75,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -154,10 +153,9 @@ "effects": { "barGlow": false, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -233,10 +231,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -305,85 +302,6 @@ "x": 12, "y": 1 }, - "id": 5, - "maxDataPoints": 20, - "options": { - "barWidthFactor": 0.4, - "effects": { - "barGlow": true, - "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true - }, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "segmentCount": 1, - "segmentSpacing": 0.3, - "shape": "circle", - "showThresholdLabels": false, - "showThresholdMarkers": false, - "sparkline": false - }, - "pluginVersion": "13.0.0-pre", - "targets": [ - { - "alias": "1", - "datasource": { - "type": "grafana-testdata-datasource" - }, - "max": 100, - "min": 1, - "noise": 22, - "refId": "A", - "scenarioId": "random_walk", - "spread": 22, - "startValue": 1 - } - ], - "title": "Spotlight", - "type": "radialbar" - }, - { - "datasource": { - "type": "grafana-testdata-datasource" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 4, - "x": 16, - "y": 1 - }, "id": 8, "maxDataPoints": 20, "options": { @@ -391,10 +309,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -460,8 +377,8 @@ "gridPos": { "h": 6, "w": 4, - "x": 0, - "y": 7 + "x": 16, + "y": 1 }, "id": 22, "maxDataPoints": 20, @@ -470,10 +387,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -539,8 +455,8 @@ "gridPos": { "h": 6, "w": 4, - "x": 4, - "y": 7 + "x": 20, + "y": 1 }, "id": 23, "maxDataPoints": 20, @@ -549,10 +465,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -593,7 +508,7 @@ "h": 1, "w": 24, "x": 0, - "y": 13 + "y": 7 }, "id": 17, "panels": [], @@ -630,9 +545,9 @@ }, "gridPos": { "h": 6, - "w": 5, + "w": 4, "x": 0, - "y": 14 + "y": 8 }, "id": 18, "maxDataPoints": 20, @@ -641,10 +556,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -709,9 +623,9 @@ }, "gridPos": { "h": 6, - "w": 5, - "x": 5, - "y": 14 + "w": 4, + "x": 4, + "y": 8 }, "id": 19, "maxDataPoints": 20, @@ -720,10 +634,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -788,9 +701,9 @@ }, "gridPos": { "h": 6, - "w": 5, - "x": 10, - "y": 14 + "w": 4, + "x": 8, + "y": 8 }, "id": 20, "maxDataPoints": 20, @@ -799,10 +712,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -867,9 +779,9 @@ }, "gridPos": { "h": 6, - "w": 5, - "x": 15, - "y": 14 + "w": 4, + "x": 12, + "y": 8 }, "id": 21, "maxDataPoints": 20, @@ -878,10 +790,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -922,7 +833,7 @@ "h": 1, "w": 24, "x": 0, - "y": 20 + "y": 14 }, "id": 24, "panels": [], @@ -963,9 +874,9 @@ }, "gridPos": { "h": 6, - "w": 6, + "w": 4, "x": 0, - "y": 21 + "y": 15 }, "id": 25, "maxDataPoints": 20, @@ -974,10 +885,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1042,9 +952,9 @@ }, "gridPos": { "h": 6, - "w": 6, - "x": 6, - "y": 21 + "w": 4, + "x": 4, + "y": 15 }, "id": 26, "maxDataPoints": 20, @@ -1053,10 +963,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1121,9 +1030,9 @@ }, "gridPos": { "h": 6, - "w": 5, - "x": 12, - "y": 21 + "w": 4, + "x": 8, + "y": 15 }, "id": 29, "maxDataPoints": 20, @@ -1132,10 +1041,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1199,10 +1107,10 @@ "overrides": [] }, "gridPos": { - "h": 7, - "w": 6, - "x": 0, - "y": 27 + "h": 6, + "w": 4, + "x": 12, + "y": 15 }, "id": 30, "maxDataPoints": 20, @@ -1211,10 +1119,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1278,10 +1185,10 @@ "overrides": [] }, "gridPos": { - "h": 7, - "w": 6, - "x": 6, - "y": 27 + "h": 6, + "w": 4, + "x": 16, + "y": 15 }, "id": 28, "maxDataPoints": 20, @@ -1290,10 +1197,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1330,7 +1236,7 @@ "h": 1, "w": 24, "x": 0, - "y": 34 + "y": 21 }, "id": 31, "panels": [], @@ -1377,7 +1283,7 @@ "h": 10, "w": 7, "x": 0, - "y": 35 + "y": 22 }, "id": 32, "maxDataPoints": 20, @@ -1386,10 +1292,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1460,7 +1365,7 @@ "h": 10, "w": 7, "x": 7, - "y": 35 + "y": 22 }, "id": 34, "maxDataPoints": 20, @@ -1469,10 +1374,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1543,7 +1447,7 @@ "h": 10, "w": 6, "x": 14, - "y": 35 + "y": 22 }, "id": 33, "maxDataPoints": 20, @@ -1552,10 +1456,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1592,7 +1495,7 @@ "h": 1, "w": 24, "x": 0, - "y": 45 + "y": 32 }, "id": 6, "panels": [], @@ -1633,20 +1536,20 @@ "h": 6, "w": 24, "x": 0, - "y": 46 + "y": 33 }, "id": 9, "maxDataPoints": 20, "options": { "barWidth": 12, "barWidthFactor": 0.4, + "barShape": "rounded", "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "endpointMarker": "glow", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1661,8 +1564,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1717,7 +1619,7 @@ "h": 6, "w": 24, "x": 0, - "y": 52 + "y": 39 }, "id": 11, "maxDataPoints": 20, @@ -1727,10 +1629,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1745,8 +1646,7 @@ "shape": "gauge", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": true, - "spotlight": true + "sparkline": true }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1773,7 +1673,7 @@ "h": 1, "w": 24, "x": 0, - "y": 58 + "y": 45 }, "id": 12, "panels": [], @@ -1815,7 +1715,7 @@ "h": 7, "w": 4, "x": 0, - "y": 59 + "y": 46 }, "id": 13, "maxDataPoints": 20, @@ -1825,10 +1725,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1843,8 +1742,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1900,7 +1798,7 @@ "h": 7, "w": 5, "x": 4, - "y": 59 + "y": 46 }, "id": 14, "maxDataPoints": 20, @@ -1910,10 +1808,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1928,8 +1825,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1984,7 +1880,7 @@ "h": 7, "w": 5, "x": 9, - "y": 59 + "y": 46 }, "id": 15, "maxDataPoints": 20, @@ -1994,10 +1890,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -2012,8 +1907,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -2068,7 +1962,7 @@ "h": 7, "w": 6, "x": 14, - "y": 59 + "y": 46 }, "id": 16, "maxDataPoints": 20, @@ -2078,10 +1972,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -2096,8 +1989,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -2124,7 +2016,7 @@ "h": 1, "w": 24, "x": 0, - "y": 66 + "y": 53 }, "id": 35, "panels": [], @@ -2155,10 +2047,10 @@ "overrides": [] }, "gridPos": { - "h": 8, - "w": 6, + "h": 5, + "w": 12, "x": 0, - "y": 67 + "y": 54 }, "id": 36, "options": { @@ -2166,10 +2058,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -2223,10 +2114,10 @@ "overrides": [] }, "gridPos": { - "h": 8, - "w": 6, - "x": 6, - "y": 67 + "h": 5, + "w": 12, + "x": 12, + "y": 54 }, "id": 37, "options": { @@ -2234,10 +2125,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -2279,4 +2169,4 @@ "title": "Panel tests - Gauge (new)", "uid": "panel-tests-gauge-new", "weekStart": "" -} \ No newline at end of file +} diff --git a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_old_to_new.v42.json b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_old_to_new.v42.json index 4a5ac97a6b5..dda0fbcd432 100644 --- a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_old_to_new.v42.json +++ b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_old_to_new.v42.json @@ -955,9 +955,7 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1162,4 +1160,4 @@ "title": "Panel tests - Old gauge to new", "uid": "panel-tests-old-gauge-to-new", "weekStart": "" -} \ No newline at end of file +} diff --git a/devenv/dev-dashboards/panel-gauge/gauge_tests_new.json b/devenv/dev-dashboards/panel-gauge/gauge_tests_new.json index b3c47c9aa7a..65cc0b0a5ae 100644 --- a/devenv/dev-dashboards/panel-gauge/gauge_tests_new.json +++ b/devenv/dev-dashboards/panel-gauge/gauge_tests_new.json @@ -71,13 +71,12 @@ "id": 1, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -148,13 +147,12 @@ "id": 4, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -225,13 +223,12 @@ "id": 3, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -299,93 +296,15 @@ "x": 12, "y": 1 }, - "id": 5, - "maxDataPoints": 20, - "options": { - "barWidthFactor": 0.4, - "effects": { - "barGlow": true, - "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true - }, - "orientation": "auto", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - }, - "segmentCount": 1, - "segmentSpacing": 0.3, - "shape": "circle", - "showThresholdLabels": false, - "showThresholdMarkers": false, - "sparkline": false - }, - "pluginVersion": "13.0.0-pre", - "targets": [ - { - "alias": "1", - "datasource": { - "type": "grafana-testdata-datasource" - }, - "max": 100, - "min": 1, - "noise": 22, - "refId": "A", - "scenarioId": "random_walk", - "spread": 22, - "startValue": 1 - } - ], - "title": "Spotlight", - "type": "radialbar" - }, - { - "datasource": { - "type": "grafana-testdata-datasource" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 4, - "x": 16, - "y": 1 - }, "id": 8, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -450,19 +369,18 @@ "gridPos": { "h": 6, "w": 4, - "x": 0, - "y": 7 + "x": 16, + "y": 1 }, "id": 22, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -527,19 +445,18 @@ "gridPos": { "h": 6, "w": 4, - "x": 4, - "y": 7 + "x": 20, + "y": 1 }, "id": 23, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -579,7 +496,7 @@ "h": 1, "w": 24, "x": 0, - "y": 13 + "y": 7 }, "id": 17, "panels": [], @@ -616,20 +533,19 @@ }, "gridPos": { "h": 6, - "w": 5, + "w": 4, "x": 0, - "y": 14 + "y": 8 }, "id": 18, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.1, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -693,20 +609,19 @@ }, "gridPos": { "h": 6, - "w": 5, - "x": 5, - "y": 14 + "w": 4, + "x": 4, + "y": 8 }, "id": 19, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.32, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -770,20 +685,19 @@ }, "gridPos": { "h": 6, - "w": 5, - "x": 10, - "y": 14 + "w": 4, + "x": 8, + "y": 8 }, "id": 20, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.57, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -847,20 +761,19 @@ }, "gridPos": { "h": 6, - "w": 5, - "x": 15, - "y": 14 + "w": 4, + "x": 12, + "y": 8 }, "id": 21, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.8, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -900,7 +813,7 @@ "h": 1, "w": 24, "x": 0, - "y": 20 + "y": 14 }, "id": 24, "panels": [], @@ -941,20 +854,19 @@ }, "gridPos": { "h": 6, - "w": 6, + "w": 4, "x": 0, - "y": 21 + "y": 15 }, "id": 25, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1018,20 +930,19 @@ }, "gridPos": { "h": 6, - "w": 6, - "x": 6, - "y": 21 + "w": 4, + "x": 4, + "y": 15 }, "id": 26, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1095,20 +1006,19 @@ }, "gridPos": { "h": 6, - "w": 5, - "x": 12, - "y": 21 + "w": 4, + "x": 8, + "y": 15 }, "id": 29, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1171,21 +1081,20 @@ "overrides": [] }, "gridPos": { - "h": 7, - "w": 6, - "x": 0, - "y": 27 + "h": 6, + "w": 4, + "x": 12, + "y": 15 }, "id": 30, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1248,21 +1157,20 @@ "overrides": [] }, "gridPos": { - "h": 7, - "w": 6, - "x": 6, - "y": 27 + "h": 6, + "w": 4, + "x": 16, + "y": 15 }, "id": 28, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1298,7 +1206,7 @@ "h": 1, "w": 24, "x": 0, - "y": 34 + "y": 21 }, "id": 31, "panels": [], @@ -1345,18 +1253,17 @@ "h": 10, "w": 7, "x": 0, - "y": 35 + "y": 22 }, "id": 32, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1426,18 +1333,17 @@ "h": 10, "w": 7, "x": 7, - "y": 35 + "y": 22 }, "id": 34, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1507,18 +1413,17 @@ "h": 10, "w": 6, "x": 14, - "y": 35 + "y": 22 }, "id": 33, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1554,7 +1459,7 @@ "h": 1, "w": 24, "x": 0, - "y": 45 + "y": 32 }, "id": 6, "panels": [], @@ -1595,20 +1500,20 @@ "h": 6, "w": 24, "x": 0, - "y": 46 + "y": 33 }, "id": 9, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "endpointMarker": "glow", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1621,8 +1526,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1677,19 +1581,18 @@ "h": 6, "w": 24, "x": 0, - "y": 52 + "y": 39 }, "id": 11, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -1703,8 +1606,7 @@ "shape": "gauge", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": true, - "spotlight": true + "sparkline": true }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1731,7 +1633,7 @@ "h": 1, "w": 24, "x": 0, - "y": 58 + "y": 45 }, "id": 12, "panels": [], @@ -1773,19 +1675,18 @@ "h": 7, "w": 4, "x": 0, - "y": 59 + "y": 46 }, "id": 13, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.49, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -1799,8 +1700,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1856,19 +1756,18 @@ "h": 7, "w": 5, "x": 4, - "y": 59 + "y": 46 }, "id": 14, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.49, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -1882,8 +1781,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1938,19 +1836,18 @@ "h": 7, "w": 5, "x": 9, - "y": 59 + "y": 46 }, "id": 15, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.84, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -1964,8 +1861,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -2020,19 +1916,18 @@ "h": 7, "w": 6, "x": 14, - "y": 59 + "y": 46 }, "id": 16, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.66, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -2046,8 +1941,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -2074,7 +1968,7 @@ "h": 1, "w": 24, "x": 0, - "y": 66 + "y": 53 }, "id": 35, "panels": [], @@ -2105,20 +1999,19 @@ "overrides": [] }, "gridPos": { - "h": 8, - "w": 6, + "h": 5, + "w": 12, "x": 0, - "y": 67 + "y": 54 }, "id": 36, "options": { + "barShape": "flat", "barWidthFactor": 0.5, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -2171,20 +2064,19 @@ "overrides": [] }, "gridPos": { - "h": 8, - "w": 6, - "x": 6, - "y": 67 + "h": 5, + "w": 12, + "x": 12, + "y": 54 }, "id": 37, "options": { + "barShape": "flat", "barWidthFactor": 0.5, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -2224,5 +2116,6 @@ "timezone": "browser", "title": "Panel tests - Gauge (new)", "uid": "panel-tests-gauge-new", - "version": 9 + "version": 22, + "weekStart": "" } diff --git a/devenv/dev-dashboards/panel-gauge/gauge_tests_old_to_new.json b/devenv/dev-dashboards/panel-gauge/gauge_tests_old_to_new.json index b071ddff802..bee1ece914e 100644 --- a/devenv/dev-dashboards/panel-gauge/gauge_tests_old_to_new.json +++ b/devenv/dev-dashboards/panel-gauge/gauge_tests_old_to_new.json @@ -956,8 +956,6 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": false } } diff --git a/packages/grafana-schema/src/raw/composable/newgauge/panelcfg/x/NewGaugePanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/newgauge/panelcfg/x/NewGaugePanelCfg_types.gen.ts index aedc041cf71..7c29eb7b463 100644 --- a/packages/grafana-schema/src/raw/composable/newgauge/panelcfg/x/NewGaugePanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/newgauge/panelcfg/x/NewGaugePanelCfg_types.gen.ts @@ -16,21 +16,19 @@ export interface GaugePanelEffects { barGlow?: boolean; centerGlow?: boolean; gradient?: boolean; - rounded?: boolean; - spotlight?: boolean; } export const defaultGaugePanelEffects: Partial = { barGlow: false, centerGlow: false, gradient: true, - rounded: false, - spotlight: false, }; export interface Options extends common.SingleStatBaseOptions { + barShape: ('flat' | 'rounded'); barWidthFactor: number; effects: GaugePanelEffects; + endpointMarker?: ('point' | 'glow' | 'none'); segmentCount: number; segmentSpacing: number; shape: ('circle' | 'gauge'); @@ -40,8 +38,10 @@ export interface Options extends common.SingleStatBaseOptions { } export const defaultOptions: Partial = { + barShape: 'flat', barWidthFactor: 0.5, effects: {}, + endpointMarker: 'point', segmentCount: 1, segmentSpacing: 0.3, shape: 'gauge', diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx index f54f3cd6f22..f59614acd53 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx @@ -1,52 +1,149 @@ -import { GaugeDimensions, toRad } from './utils'; +import { useId, memo, HTMLAttributes, ReactNode } from 'react'; -export interface RadialArcPathProps { - startAngle: number; - dimensions: GaugeDimensions; - color: string; - glowFilter?: string; +import { FieldDisplay } from '@grafana/data'; + +import { getBarEndcapColors, getGradientCss, getEndpointMarkerColors } from './colors'; +import { RadialShape, RadialGaugeDimensions, GradientStop } from './types'; +import { drawRadialArcPath, toRad } from './utils'; + +export interface RadialArcPathPropsBase { arcLengthDeg: number; + barEndcaps?: boolean; + dimensions: RadialGaugeDimensions; + fieldDisplay: FieldDisplay; roundedBars?: boolean; + shape: RadialShape; + endpointMarker?: 'point' | 'glow'; + startAngle: number; + glowFilter?: string; + endpointMarkerGlowFilter?: string; } -export function RadialArcPath({ - startAngle: angle, - dimensions, - color, - glowFilter, - arcLengthDeg, - roundedBars, -}: RadialArcPathProps) { - const { radius, centerX, centerY, barWidth } = dimensions; +interface RadialArcPathPropsWithColor extends RadialArcPathPropsBase { + color: string; +} - if (arcLengthDeg === 360) { - // For some reason a 100% full arc cannot be rendered - arcLengthDeg = 359.99; +interface RadialArcPathPropsWithGradient extends RadialArcPathPropsBase { + gradient: GradientStop[]; +} + +type RadialArcPathProps = RadialArcPathPropsWithColor | RadialArcPathPropsWithGradient; + +const ENDPOINT_MARKER_MIN_ANGLE = 10; +const DOT_OPACITY = 0.5; +const DOT_RADIUS_FACTOR = 0.4; +const MAX_DOT_RADIUS = 8; + +export const RadialArcPath = memo( + ({ + arcLengthDeg, + dimensions, + fieldDisplay, + roundedBars, + shape, + endpointMarker, + barEndcaps, + startAngle: angle, + glowFilter, + endpointMarkerGlowFilter, + ...rest + }: RadialArcPathProps) => { + const id = useId(); + + const bgDivStyle: HTMLAttributes['style'] = { width: '100%', height: '100%' }; + if ('color' in rest) { + bgDivStyle.backgroundColor = rest.color; + } else { + bgDivStyle.backgroundImage = getGradientCss(rest.gradient, shape); + } + + const { radius, centerX, centerY, barWidth } = dimensions; + + const path = drawRadialArcPath(angle, arcLengthDeg, dimensions, roundedBars); + + const startRadians = toRad(angle); + const endRadians = toRad(angle + arcLengthDeg); + + const xStart = centerX + radius * Math.cos(startRadians); + const yStart = centerY + radius * Math.sin(startRadians); + const xEnd = centerX + radius * Math.cos(endRadians); + const yEnd = centerY + radius * Math.sin(endRadians); + + const dotRadius = + endpointMarker === 'point' ? Math.min((barWidth / 2) * DOT_RADIUS_FACTOR, MAX_DOT_RADIUS) : barWidth / 2; + + let barEndcapColors: [string, string] | undefined; + let endpointMarks: ReactNode = null; + if ('gradient' in rest) { + if (endpointMarker && (rest.gradient?.length ?? 0) > 0) { + switch (endpointMarker) { + case 'point': + const [pointColorStart, pointColorEnd] = getEndpointMarkerColors( + rest.gradient!, + fieldDisplay.display.percent + ); + endpointMarks = ( + <> + {arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE && ( + + )} + + + ); + break; + case 'glow': + const offsetAngle = toRad(ENDPOINT_MARKER_MIN_ANGLE); + const xStartMark = centerX + radius * Math.cos(endRadians + offsetAngle); + const yStartMark = centerY + radius * Math.sin(endRadians + offsetAngle); + endpointMarks = + arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE ? ( + + ) : null; + break; + default: + break; + } + } + + if (barEndcaps) { + barEndcapColors = getBarEndcapColors(rest.gradient, fieldDisplay.display.percent); + } + } + + return ( + <> + {/* FIXME: optimize this by only using clippath + foreign obj for gradients */} + + + + + + +
+ + {barEndcapColors?.[0] && } + {barEndcapColors?.[1] && ( + + )} + + + {endpointMarks} + + ); } +); - const startRadians = toRad(angle); - const endRadians = toRad(angle + arcLengthDeg); - - let x1 = centerX + radius * Math.cos(startRadians); - let y1 = centerY + radius * Math.sin(startRadians); - let x2 = centerX + radius * Math.cos(endRadians); - let y2 = centerY + radius * Math.sin(endRadians); - - const largeArc = arcLengthDeg > 180 ? 1 : 0; - - const path = ['M', x1, y1, 'A', radius, radius, 0, largeArc, 1, x2, y2].join(' '); - - return ( - - ); -} +RadialArcPath.displayName = 'RadialArcPath'; diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx index 2bacc3af3e8..719ec52c625 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx @@ -1,97 +1,64 @@ -import { GrafanaTheme2 } from '@grafana/data'; +import { FALLBACK_COLOR, FieldDisplay } from '@grafana/data'; import { useTheme2 } from '../../themes/ThemeContext'; import { RadialArcPath } from './RadialArcPath'; -import { RadialColorDefs } from './RadialColorDefs'; -import { GaugeDimensions, toRad } from './utils'; +import { RadialShape, RadialGaugeDimensions, GradientStop } from './types'; export interface RadialBarProps { - dimensions: GaugeDimensions; - colorDefs: RadialColorDefs; - angleRange: number; angle: number; - startAngle: number; + angleRange: number; + dimensions: RadialGaugeDimensions; + fieldDisplay: FieldDisplay; + gradient?: GradientStop[]; roundedBars?: boolean; - spotlightStroke: string; + endpointMarker?: 'point' | 'glow'; + shape: RadialShape; + startAngle: number; glowFilter?: string; + endpointMarkerGlowFilter?: string; } export function RadialBar({ - dimensions, - colorDefs, - angleRange, angle, - startAngle, + angleRange, + dimensions, + fieldDisplay, + gradient, roundedBars, - spotlightStroke, + endpointMarker, + shape, + startAngle, glowFilter, + endpointMarkerGlowFilter, }: RadialBarProps) { const theme = useTheme2(); - + const colorProps = gradient ? { gradient } : { color: fieldDisplay.display.color ?? FALLBACK_COLOR }; return ( <> - - {/** Track */} - - {/** The colored bar */} - - {spotlightStroke && angle > 8 && ( - - )} - - {colorDefs.getDefs()} + {/** Track */} + + {/** The colored bar */} + ); } - -interface SpotlightEffectProps { - dimensions: GaugeDimensions; - angle: number; - glowFilter?: string; - spotlightStroke: string; - theme: GrafanaTheme2; - roundedBars?: boolean; -} - -function SpotlightSquareEffect({ dimensions, angle, glowFilter, spotlightStroke, roundedBars }: SpotlightEffectProps) { - const { radius, centerX, centerY, barWidth } = dimensions; - - const angleRadian = toRad(angle); - const x1 = centerX + radius * Math.cos(angleRadian - 0.2); - const y1 = centerY + radius * Math.sin(angleRadian - 0.2); - const x2 = centerX + radius * Math.cos(angleRadian); - const y2 = centerY + radius * Math.sin(angleRadian); - - const path = ['M', x1, y1, 'A', radius, radius, 0, 0, 1, x2, y2].join(' '); - - return ( - - ); -} diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx index dbc93d2da7f..b51cb4ce2f1 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx @@ -1,126 +1,74 @@ -import { FieldDisplay } from '@grafana/data'; +import { memo } from 'react'; + +import { FALLBACK_COLOR, FieldDisplay } from '@grafana/data'; import { useTheme2 } from '../../themes/ThemeContext'; import { RadialArcPath } from './RadialArcPath'; -import { RadialColorDefs } from './RadialColorDefs'; -import { GaugeDimensions } from './utils'; +import { RadialShape, RadialGaugeDimensions, GradientStop } from './types'; +import { + getAngleBetweenSegments, + getFieldConfigMinMax, + getFieldDisplayProcessor, + getOptimalSegmentCount, +} from './utils'; export interface RadialBarSegmentedProps { fieldDisplay: FieldDisplay; - dimensions: GaugeDimensions; - colorDefs: RadialColorDefs; + dimensions: RadialGaugeDimensions; angleRange: number; startAngle: number; glowFilter?: string; segmentCount: number; segmentSpacing: number; + shape: RadialShape; + gradient?: GradientStop[]; } -export function RadialBarSegmented({ - fieldDisplay, - dimensions, - startAngle, - angleRange, - glowFilter, - segmentCount, - segmentSpacing, - colorDefs, -}: RadialBarSegmentedProps) { - const segments: React.ReactNode[] = []; - const theme = useTheme2(); - const segmentCountAdjusted = getOptimalSegmentCount(dimensions, segmentSpacing, segmentCount, angleRange); - const min = fieldDisplay.field.min ?? 0; - const max = fieldDisplay.field.max ?? 100; - const value = fieldDisplay.display.numeric; - const angleBetweenSegments = getAngleBetweenSegments(segmentSpacing, segmentCount, angleRange); - const segmentArcLengthDeg = angleRange / segmentCountAdjusted - angleBetweenSegments; +export const RadialBarSegmented = memo( + ({ + fieldDisplay, + dimensions, + startAngle, + angleRange, + glowFilter, + gradient, + segmentCount, + segmentSpacing, + shape, + }: RadialBarSegmentedProps) => { + const theme = useTheme2(); + const segments: React.ReactNode[] = []; + const segmentCountAdjusted = getOptimalSegmentCount(dimensions, segmentSpacing, segmentCount, angleRange); + const [min, max] = getFieldConfigMinMax(fieldDisplay); + const value = fieldDisplay.display.numeric; + const angleBetweenSegments = getAngleBetweenSegments(segmentSpacing, segmentCount, angleRange); + const segmentArcLengthDeg = angleRange / segmentCountAdjusted - angleBetweenSegments; + const displayProcessor = getFieldDisplayProcessor(fieldDisplay); - for (let i = 0; i < segmentCountAdjusted; i++) { - const angleValue = min + ((max - min) / segmentCountAdjusted) * i; - const angleColor = colorDefs.getSegmentColor(angleValue); - const segmentAngle = startAngle + (angleRange / segmentCountAdjusted) * i + 0.01; - const segmentColor = angleValue >= value ? theme.colors.action.hover : angleColor; + for (let i = 0; i < segmentCountAdjusted; i++) { + const angleValue = min + ((max - min) / segmentCountAdjusted) * i; + const segmentAngle = startAngle + (angleRange / segmentCountAdjusted) * i + 0.01; + const segmentColor = + angleValue >= value ? theme.colors.border.medium : (displayProcessor(angleValue).color ?? FALLBACK_COLOR); + const colorProps = angleValue < value && gradient ? { gradient } : { color: segmentColor }; - segments.push( - - ); + segments.push( + + ); + } + + return {segments}; } +); - return ( - <> - {segments} - {colorDefs.getDefs()} - - ); -} - -export function getAngleBetweenSegments(segmentSpacing: number, segmentCount: number, range: number) { - // Max spacing is 8 degrees between segments - // Changing this constant could be considered a breaking change - const maxAngleBetweenSegments = Math.max(range / 1.5 / segmentCount, 2); - return segmentSpacing * maxAngleBetweenSegments; -} - -function getOptimalSegmentCount( - dimensions: GaugeDimensions, - segmentSpacing: number, - segmentCount: number, - range: number -) { - const angleBetweenSegments = getAngleBetweenSegments(segmentSpacing, segmentCount, range); - - const innerRadius = dimensions.radius - dimensions.barWidth / 2; - const circumference = Math.PI * innerRadius * 2 * (range / 360); - const maxSegments = Math.floor(circumference / (angleBetweenSegments + 3)); - - return Math.min(maxSegments, segmentCount); -} - -// export function RadialSegmentLine({ -// gaugeId, -// center, -// angle, -// size, -// color, -// barWidth, -// roundedBars, -// glow, -// margin, -// segmentWidth, -// }: RadialSegmentProps) { -// const arcSize = size - barWidth; -// const radius = arcSize / 2 - margin; - -// const angleRad = (Math.PI * (angle - 90)) / 180; -// const lineLength = radius - barWidth; - -// const x1 = center + radius * Math.cos(angleRad); -// const y1 = center + radius * Math.sin(angleRad); -// const x2 = center + lineLength * Math.cos(angleRad); -// const y2 = center + lineLength * Math.sin(angleRad); - -// return ( -// -// ); -// } +RadialBarSegmented.displayName = 'RadialBarSegmented'; diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx deleted file mode 100644 index 6bcf4876880..00000000000 --- a/packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import tinycolor from 'tinycolor2'; - -import { DisplayProcessor, FALLBACK_COLOR, FieldDisplay, getFieldColorMode, GrafanaTheme2 } from '@grafana/data'; - -import { RadialGradientMode, RadialShape } from './RadialGauge'; -import { GaugeDimensions } from './utils'; - -export interface RadialColorDefsOptions { - gradient: RadialGradientMode; - fieldDisplay: FieldDisplay; - theme: GrafanaTheme2; - dimensions: GaugeDimensions; - shape: RadialShape; - gaugeId: string; - displayProcessor: DisplayProcessor; -} - -export class RadialColorDefs { - private colorToIds: Record = {}; - private defs: React.ReactNode[] = []; - - constructor(private options: RadialColorDefsOptions) {} - - getSegmentColor(forValue: number): string { - const { displayProcessor } = this.options; - const baseColor = displayProcessor(forValue).color ?? FALLBACK_COLOR; - - return this.getColor(baseColor, true); - } - - getColor(baseColor: string, forSegment?: boolean): string { - const { gradient, dimensions, gaugeId, fieldDisplay, shape, theme } = this.options; - - const id = `value-color-${baseColor}-${gaugeId}`; - - if (this.colorToIds[id]) { - return this.colorToIds[id]; - } - - // If no gradient, just return the base color - if (gradient === 'none') { - this.colorToIds[id] = baseColor; - return baseColor; - } - - const returnColor = (this.colorToIds[id] = `url(#${id})`); - const colorModeId = fieldDisplay.field.color?.mode; - const colorMode = getFieldColorMode(colorModeId); - const valuePercent = fieldDisplay.display.percent ?? 0; - - // Handle continusous color modes first - // If it's a segment color we don't want to do continuous gradients - if (colorMode.isContinuous && colorMode.getColors && !forSegment) { - const colors = colorMode.getColors(theme); - const count = colors.length; - - this.defs.push( - - {colors.map((stopColor, i) => ( - - ))} - - ); - - return returnColor; - } - - // For value based colors we want to stay more true to the specific color - // So a radial gradient that adds a bit of light and shade works best - if (colorMode.isByValue) { - const color1 = tinycolor(baseColor).darken(5); - - this.defs.push( - - - - - - ); - - return returnColor; - } - - // For fixed / palette based color scales we can create a more fun - // hue and light based linear gradient that we rotate/move with the value - - const x2 = shape === 'circle' ? 0 : dimensions.centerX + dimensions.radius; - const y2 = shape === 'circle' ? dimensions.centerY + dimensions.radius : 0; - const color1 = tinycolor(baseColor).spin(-20).darken(5); - const color2 = tinycolor(baseColor).saturate(20).spin(20).brighten(10); - - // this makes it so the gradient is always brightest at the current value - const transform = - shape === 'circle' - ? `rotate(${360 * valuePercent - 180} ${dimensions.centerX} ${dimensions.centerY})` - : `translate(-${dimensions.radius * 2 * (1 - valuePercent)}, 0)`; - - this.defs.push( - - {theme.isDark ? ( - <> - - - - ) : ( - <> - - - - )} - - ); - - return returnColor; - } - - getMainBarColor(): string { - return this.getColor(this.options.fieldDisplay.display.color ?? FALLBACK_COLOR); - } - - getDefs(): React.ReactNode[] { - return this.defs; - } -} diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.story.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.story.tsx index c098beda886..b0574ef3f87 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.story.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.story.tsx @@ -13,7 +13,8 @@ import { FieldColorModeId } from '@grafana/schema'; import { useTheme2 } from '../../themes/ThemeContext'; import { Stack } from '../Layout/Stack/Stack'; -import { RadialGauge, RadialGaugeProps, RadialGradientMode, RadialShape, RadialTextMode } from './RadialGauge'; +import { RadialGauge, RadialGaugeProps } from './RadialGauge'; +import { RadialShape, RadialTextMode } from './types'; interface StoryProps extends RadialGaugeProps { value: number; @@ -31,10 +32,27 @@ const meta: Meta = { controls: { exclude: ['theme', 'values', 'vizCount'], }, + a11y: { + config: { + rules: [ + { + id: 'scrollable-region-focusable', + selector: 'body', + enabled: false, + }, + // NOTE: this is necessary due to a false positive with the filered svg glow in one of the examples. + // The color-contrast in this component should be accessible! + { + id: 'color-contrast', + selector: 'text', + enabled: false, + }, + ], + }, + }, }, args: { barWidthFactor: 0.2, - spotlight: false, glowBar: false, glowCenter: false, sparkline: false, @@ -42,7 +60,7 @@ const meta: Meta = { width: 200, height: 200, shape: 'circle', - gradient: 'none', + gradient: false, seriesCount: 1, segmentCount: 0, segmentSpacing: 0.2, @@ -56,14 +74,14 @@ const meta: Meta = { width: { control: { type: 'range', min: 50, max: 600 } }, height: { control: { type: 'range', min: 50, max: 600 } }, value: { control: { type: 'range', min: 0, max: 110 } }, - spotlight: { control: 'boolean' }, roundedBars: { control: 'boolean' }, sparkline: { control: 'boolean' }, thresholdsBar: { control: 'boolean' }, - gradient: { control: { type: 'radio' } }, + gradient: { control: { type: 'boolean' } }, seriesCount: { control: { type: 'range', min: 1, max: 20 } }, segmentCount: { control: { type: 'range', min: 0, max: 100 } }, segmentSpacing: { control: { type: 'range', min: 0, max: 1, step: 0.01 } }, + endpointMarker: { control: { type: 'select' }, options: ['none', 'point', 'glow'] }, colorScheme: { control: { type: 'select' }, options: [ @@ -102,57 +120,17 @@ export const Examples: StoryFn = (args) => {
Bar width
- - - - + + + +
Effects
- - - - + + + +
Shape: Gauge & color scale
@@ -160,14 +138,14 @@ export const Examples: StoryFn = (args) => { value={40} shape="gauge" width={250} - gradient="auto" + gradient colorScheme={FieldColorModeId.ContinuousGrYlRd} glowCenter={true} barWidthFactor={0.6} /> = (args) => { value={args.value ?? 70} color="blue" shape="gauge" - gradient="auto" + gradient sparkline={true} - spotlight glowBar={true} glowCenter={true} barWidthFactor={0.2} @@ -194,9 +171,8 @@ export const Examples: StoryFn = (args) => { value={args.value ?? 30} color="green" shape="gauge" - gradient="auto" + gradient sparkline={true} - spotlight glowBar={true} glowCenter={true} barWidthFactor={0.8} @@ -206,9 +182,8 @@ export const Examples: StoryFn = (args) => { color="red" shape="gauge" width={250} - gradient="auto" + gradient sparkline={true} - spotlight glowBar={true} glowCenter={true} barWidthFactor={0.2} @@ -218,9 +193,8 @@ export const Examples: StoryFn = (args) => { color="red" width={250} shape="gauge" - gradient="auto" + gradient sparkline={true} - spotlight glowBar={true} glowCenter={true} barWidthFactor={0.8} @@ -231,7 +205,7 @@ export const Examples: StoryFn = (args) => { = (args) => { = (args) => { = (args) => { = (args) => { value={args.value ?? 80} width={250} colorScheme={FieldColorModeId.ContinuousGrYlRd} - spotlight shape="gauge" - gradient="auto" + gradient glowBar={true} glowCenter={true} segmentCount={40} @@ -285,10 +257,9 @@ export const Examples: StoryFn = (args) => { @@ -296,7 +267,7 @@ export const Examples: StoryFn = (args) => { value={args.value ?? 70} width={250} colorScheme={FieldColorModeId.Thresholds} - gradient="auto" + gradient glowCenter={true} thresholdsBar={true} roundedBars={false} @@ -307,7 +278,7 @@ export const Examples: StoryFn = (args) => { value={args.value ?? 70} width={250} colorScheme={FieldColorModeId.Thresholds} - gradient="auto" + gradient glowCenter={true} thresholdsBar={true} roundedBars={false} @@ -347,14 +318,12 @@ export const Temp: StoryFn = (args) => { shape="gauge" roundedBars={false} barWidthFactor={0.8} - spotlight /> ); }; interface ExampleProps { - gradient?: RadialGradientMode; color?: string; seriesName?: string; value?: number; @@ -363,7 +332,7 @@ interface ExampleProps { max?: number; width?: number; height?: number; - spotlight?: boolean; + gradient?: boolean; glowBar?: boolean; glowCenter?: boolean; barWidthFactor?: number; @@ -376,12 +345,12 @@ interface ExampleProps { roundedBars?: boolean; thresholdsBar?: boolean; colorScheme?: FieldColorModeId; + endpointMarker?: RadialGaugeProps['endpointMarker']; decimals?: number; showScaleLabels?: boolean; } export function RadialGaugeExample({ - gradient = 'none', color, seriesName = 'Server A', value = 70, @@ -390,7 +359,7 @@ export function RadialGaugeExample({ max = 100, width = 200, height = 200, - spotlight = false, + gradient = false, glowBar = false, glowCenter = false, barWidthFactor = 0.4, @@ -403,6 +372,7 @@ export function RadialGaugeExample({ roundedBars = false, thresholdsBar = false, colorScheme = FieldColorModeId.Thresholds, + endpointMarker = 'glow', decimals = 0, showScaleLabels, }: ExampleProps) { @@ -480,7 +450,6 @@ export function RadialGaugeExample({ barWidthFactor={barWidthFactor} gradient={gradient} shape={shape} - spotlight={spotlight} glowBar={glowBar} glowCenter={glowCenter} textMode={textMode} @@ -490,6 +459,7 @@ export function RadialGaugeExample({ roundedBars={roundedBars} thresholdsBar={thresholdsBar} showScaleLabels={showScaleLabels} + endpointMarker={endpointMarker} /> ); } diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.test.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.test.tsx index e2fed36ec3f..783e3b764da 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.test.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.test.tsx @@ -1,13 +1,28 @@ import { render, screen } from '@testing-library/react'; +import { ComponentProps } from 'react'; import { RadialGaugeExample } from './RadialGauge.story'; describe('RadialGauge', () => { - it('should render', () => { - render(); - - expect(screen.getByRole('img')).toBeInTheDocument(); - }); + it.each([ + { description: 'default', props: {} }, + { description: 'gauge shape', props: { shape: 'gauge' } }, + { description: 'with gradient', props: { gradient: true } }, + { description: 'with glow bar', props: { glowBar: true } }, + { description: 'with glow center', props: { glowCenter: true } }, + { description: 'with segments', props: { segmentCount: 5 } }, + { description: 'with rounded bars', props: { roundedBars: true } }, + { description: 'with endpoint marker glow', props: { roundedBars: true, endpointMarker: 'glow' } }, + { description: 'with endpoint marker point', props: { roundedBars: true, endpointMarker: 'point' } }, + { description: 'with thresholds bar', props: { thresholdsBar: true } }, + { description: 'with sparkline', props: { sparkline: true } }, + ] satisfies Array<{ description: string; props?: ComponentProps }>)( + 'should render $description without throwing', + ({ props }) => { + render(); + expect(screen.getByRole('img')).toBeInTheDocument(); + } + ); it('should render threshold labels', () => { render(); diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx index fadabf8ec72..1251a364230 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx @@ -1,14 +1,7 @@ import { css, cx } from '@emotion/css'; -import { isNumber } from 'lodash'; import { useId } from 'react'; -import { - DisplayValueAlignmentFactors, - FieldDisplay, - getDisplayProcessor, - GrafanaTheme2, - TimeRange, -} from '@grafana/data'; +import { DisplayValueAlignmentFactors, FALLBACK_COLOR, FieldDisplay, GrafanaTheme2, TimeRange } from '@grafana/data'; import { t } from '@grafana/i18n'; import { useStyles2, useTheme2 } from '../../themes/ThemeContext'; @@ -16,12 +9,13 @@ import { getFormattedThresholds } from '../Gauge/utils'; import { RadialBar } from './RadialBar'; import { RadialBarSegmented } from './RadialBarSegmented'; -import { RadialColorDefs } from './RadialColorDefs'; import { RadialScaleLabels } from './RadialScaleLabels'; import { RadialSparkline } from './RadialSparkline'; import { RadialText } from './RadialText'; import { ThresholdsBar } from './ThresholdsBar'; +import { buildGradientColors } from './colors'; import { GlowGradient, MiddleCircleGlow, SpotlightGradient } from './effects'; +import { RadialShape, RadialTextMode } from './types'; import { calculateDimensions, getValueAngleForValue } from './utils'; export interface RadialGaugeProps { @@ -32,7 +26,7 @@ export interface RadialGaugeProps { * Circle or gauge (partial circle) */ shape?: RadialShape; - gradient?: RadialGradientMode; + gradient?: boolean; /** * Bar width is always relative to size of the gauge. * But this gives you control over the width relative to size. @@ -40,12 +34,14 @@ export interface RadialGaugeProps { * Defaults to 0.4 **/ barWidthFactor?: number; - /** Adds a white spotlight for the end position */ - spotlight?: boolean; glowBar?: boolean; glowCenter?: boolean; roundedBars?: boolean; thresholdsBar?: boolean; + /** + * Specify if an endpoint marker should be shown at the end of the bar + */ + endpointMarker?: 'point' | 'glow'; /** * Number of segments depends on size of gauge but this * factor 1-10 gives you relative control @@ -75,10 +71,6 @@ export interface RadialGaugeProps { timeRange?: TimeRange; } -export type RadialGradientMode = 'none' | 'auto'; -export type RadialTextMode = 'auto' | 'value_and_name' | 'value' | 'name' | 'none'; -export type RadialShape = 'circle' | 'gauge'; - /** * https://developers.grafana.com/ui/latest/index.html?path=/docs/plugins-radialgauge--docs */ @@ -87,9 +79,8 @@ export function RadialGauge(props: RadialGaugeProps) { width = 256, height = 256, shape = 'circle', - gradient = 'none', + gradient = false, barWidthFactor = 0.4, - spotlight = false, glowBar = false, glowCenter = false, textMode = 'auto', @@ -99,6 +90,7 @@ export function RadialGauge(props: RadialGaugeProps) { roundedBars = true, thresholdsBar = false, showScaleLabels = false, + endpointMarker, onClick, values, } = props; @@ -121,7 +113,8 @@ export function RadialGauge(props: RadialGaugeProps) { for (let barIndex = 0; barIndex < values.length; barIndex++) { const displayValue = values[barIndex]; const { angle, angleRange } = getValueAngleForValue(displayValue, startAngle, endAngle); - const color = displayValue.display.color ?? 'gray'; + const gradientStops = buildGradientColors(gradient, theme, displayValue); + const color = displayValue.display.color ?? FALLBACK_COLOR; const dimensions = calculateDimensions( width, height, @@ -134,20 +127,12 @@ export function RadialGauge(props: RadialGaugeProps) { showScaleLabels ); - const displayProcessor = getFieldDisplayProcessor(displayValue); + // FIXME: I want to move the ids for these filters into a context which the children + // can reference via a hook, rather than passing them down as props const spotlightGradientId = `spotlight-${barIndex}-${gaugeId}`; const glowFilterId = `glow-${gaugeId}`; - const colorDefs = new RadialColorDefs({ - gradient, - fieldDisplay: displayValue, - theme, - dimensions, - shape, - gaugeId, - displayProcessor, - }); - if (spotlight && theme.isDark) { + if (endpointMarker === 'glow') { defs.push( ); } else { @@ -179,13 +165,16 @@ export function RadialGauge(props: RadialGaugeProps) { ); } @@ -245,7 +234,8 @@ export function RadialGauge(props: RadialGaugeProps) { angleRange={angleRange} roundedBars={roundedBars} glowFilter={`url(#${glowFilterId})`} - colorDefs={colorDefs} + shape={shape} + gradient={gradientStops} /> ); } @@ -291,17 +281,6 @@ export function RadialGauge(props: RadialGaugeProps) { ); } -function getFieldDisplayProcessor(displayValue: FieldDisplay) { - if (displayValue.view && isNumber(displayValue.colIndex)) { - const dp = displayValue.view.getFieldDisplayProcessor(displayValue.colIndex); - if (dp) { - return dp; - } - } - - return getDisplayProcessor(); -} - function getStyles(theme: GrafanaTheme2) { return { vizWrapper: css({ diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialScaleLabels.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialScaleLabels.tsx index 994b3b35eac..6588150602a 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialScaleLabels.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialScaleLabels.tsx @@ -1,87 +1,84 @@ +import { memo } from 'react'; + import { FieldDisplay, GrafanaTheme2, Threshold } from '@grafana/data'; import { t } from '@grafana/i18n'; import { measureText } from '../../utils/measureText'; -import { GaugeDimensions, toCartesian } from './utils'; +import { RadialGaugeDimensions } from './types'; +import { getFieldConfigMinMax, toCartesian } from './utils'; interface RadialScaleLabelsProps { fieldDisplay: FieldDisplay; theme: GrafanaTheme2; thresholds: Threshold[]; - dimensions: GaugeDimensions; + dimensions: RadialGaugeDimensions; startAngle: number; endAngle: number; angleRange: number; } -export function RadialScaleLabels({ - fieldDisplay, - thresholds, - theme, - dimensions, - startAngle, - endAngle, - angleRange, -}: RadialScaleLabelsProps) { - const { centerX, centerY, scaleLabelsFontSize, scaleLabelsRadius } = dimensions; +const LINE_HEIGHT_FACTOR = 1.2; - const fieldConfig = fieldDisplay.field; - const min = fieldConfig.min ?? 0; - const max = fieldConfig.max ?? 100; +export const RadialScaleLabels = memo( + ({ fieldDisplay, thresholds, theme, dimensions, startAngle, endAngle, angleRange }: RadialScaleLabelsProps) => { + const { centerX, centerY, scaleLabelsFontSize, scaleLabelsRadius } = dimensions; + const [min, max] = getFieldConfigMinMax(fieldDisplay); - const fontSize = scaleLabelsFontSize; - const textLineHeight = scaleLabelsFontSize * 1.2; - const radius = scaleLabelsRadius - textLineHeight; + const fontSize = scaleLabelsFontSize; + const textLineHeight = scaleLabelsFontSize * LINE_HEIGHT_FACTOR; + const radius = scaleLabelsRadius - textLineHeight; - function getTextPosition(text: string, value: number, index: number) { - const isLast = index === thresholds.length - 1; - const isFirst = index === 0; + function getTextPosition(text: string, value: number, index: number) { + const isLast = index === thresholds.length - 1; + const isFirst = index === 0; - let valueDeg = ((value - min) / (max - min)) * angleRange; - let finalAngle = startAngle + valueDeg; + let valueDeg = ((value - min) / (max - min)) * angleRange; + let finalAngle = startAngle + valueDeg; - // Now adjust the final angle based on the label text width and the labels position on the arc - let measure = measureText(text, fontSize, theme.typography.fontWeightMedium); - let textWidthAngle = (measure.width / (2 * Math.PI * radius)) * angleRange; + // Now adjust the final angle based on the label text width and the labels position on the arc + let measure = measureText(text, fontSize, theme.typography.fontWeightMedium); + let textWidthAngle = (measure.width / (2 * Math.PI * radius)) * angleRange; - // the centering is different for gauge or circle shapes for some reason - finalAngle -= endAngle < 180 ? textWidthAngle : textWidthAngle / 2; + // the centering is different for gauge or circle shapes for some reason + finalAngle -= endAngle < 180 ? textWidthAngle : textWidthAngle / 2; - // For circle gauges we need to shift the first label more - if (isFirst) { - finalAngle += textWidthAngle; + // For circle gauges we need to shift the first label more + if (isFirst) { + finalAngle += textWidthAngle; + } + + // For circle gauges we need to shift the last label more + if (isLast && endAngle === 360) { + finalAngle -= textWidthAngle; + } + + const position = toCartesian(centerX, centerY, radius, finalAngle); + + return { ...position, transform: `rotate(${finalAngle}, ${position.x}, ${position.y})` }; } - // For circle gauges we need to shift the last label more - if (isLast && endAngle === 360) { - finalAngle -= textWidthAngle; - } - - const position = toCartesian(centerX, centerY, radius, finalAngle); - - return { ...position, transform: `rotate(${finalAngle}, ${position.x}, ${position.y})` }; + return ( + + {thresholds.map((threshold, index) => { + const labelPos = getTextPosition(String(threshold.value), threshold.value, index); + return ( + + {threshold.value} + + ); + })} + + ); } +); - return ( - - {thresholds.map((threshold, index) => { - const labelPos = getTextPosition(String(threshold.value), threshold.value, index); - - return ( - - {threshold.value} - - ); - })} - - ); -} +RadialScaleLabels.displayName = 'RadialScaleLabels'; diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx index acb255a3f3e..2d6c45a14bf 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx @@ -1,49 +1,76 @@ +import { memo, useMemo } from 'react'; + import { FieldDisplay, GrafanaTheme2, FieldConfig } from '@grafana/data'; import { GraphFieldConfig, GraphGradientMode, LineInterpolation } from '@grafana/schema'; import { Sparkline } from '../Sparkline/Sparkline'; -import { RadialShape, RadialTextMode } from './RadialGauge'; -import { GaugeDimensions } from './utils'; +import { RadialShape, RadialTextMode, RadialGaugeDimensions } from './types'; interface RadialSparklineProps { - sparkline: FieldDisplay['sparkline']; - dimensions: GaugeDimensions; - theme: GrafanaTheme2; color?: string; - shape?: RadialShape; + dimensions: RadialGaugeDimensions; + shape: RadialShape; + sparkline: FieldDisplay['sparkline']; textMode: Exclude; + theme: GrafanaTheme2; } -export function RadialSparkline({ sparkline, dimensions, theme, color, shape, textMode }: RadialSparklineProps) { - const { radius, barWidth } = dimensions; - if (!sparkline) { - return null; +const SPARKLINE_HEIGHT_DIVISOR = 4; +const SPARKLINE_HEIGHT_DIVISOR_NAME_AND_VALUE = 4; +const SPARKLINE_WIDTH_FACTOR_ARC = 1.4; +const SPARKLINE_WIDTH_FACTOR_CIRCLE = 1.6; +const SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE = 4; +const SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE_NAME_AND_VALUE = 3.3; +const SPARKLINE_SPACING = 8; + +export function getSparklineDimensions( + radius: number, + barWidth: number, + showNameAndValue: boolean, + shape: RadialShape +): { width: number; height: number } { + const height = radius / (showNameAndValue ? SPARKLINE_HEIGHT_DIVISOR_NAME_AND_VALUE : SPARKLINE_HEIGHT_DIVISOR); + const width = radius * (shape === 'gauge' ? SPARKLINE_WIDTH_FACTOR_ARC : SPARKLINE_WIDTH_FACTOR_CIRCLE) - barWidth; + return { width, height }; +} + +export const RadialSparkline = memo( + ({ sparkline, dimensions, theme, color, shape, textMode }: RadialSparklineProps) => { + const { radius, barWidth } = dimensions; + + const showNameAndValue = textMode === 'value_and_name'; + const { width, height } = getSparklineDimensions(radius, barWidth, showNameAndValue, shape); + const topPos = + shape === 'gauge' + ? dimensions.gaugeBottomY - height - SPARKLINE_SPACING + : `calc(50% + ${radius / (showNameAndValue ? SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE_NAME_AND_VALUE : SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE)}px)`; + + const config: FieldConfig = useMemo( + () => ({ + color: { + mode: 'fixed', + fixedColor: color ?? 'blue', + }, + custom: { + gradientMode: GraphGradientMode.Opacity, + fillOpacity: 40, + lineInterpolation: LineInterpolation.Smooth, + }, + }), + [color] + ); + + if (!sparkline) { + return null; + } + + return ( +
+ +
+ ); } +); - const showNameAndValue = textMode === 'value_and_name'; - const height = radius / (showNameAndValue ? 4 : 3); - const width = radius * (shape === 'gauge' ? 1.6 : 1.4) - barWidth; - const topPos = - shape === 'gauge' - ? `${dimensions.gaugeBottomY - height}px` - : `calc(50% + ${radius / (showNameAndValue ? 3.3 : 4)}px)`; - - const config: FieldConfig = { - color: { - mode: 'fixed', - fixedColor: color ?? 'blue', - }, - custom: { - gradientMode: GraphGradientMode.Opacity, - fillOpacity: 40, - lineInterpolation: LineInterpolation.Smooth, - }, - }; - - return ( -
- -
- ); -} +RadialSparkline.displayName = 'RadialSparkline'; diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialText.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialText.tsx index 51a1c64c842..69ab16e450e 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialText.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialText.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/css'; +import { memo } from 'react'; import { DisplayValue, @@ -11,13 +12,12 @@ import { import { useStyles2 } from '../../themes/ThemeContext'; import { calculateFontSize } from '../../utils/measureText'; -import { RadialShape, RadialTextMode } from './RadialGauge'; -import { GaugeDimensions } from './utils'; +import { RadialShape, RadialTextMode, RadialGaugeDimensions } from './types'; interface RadialTextProps { displayValue: DisplayValue; theme: GrafanaTheme2; - dimensions: GaugeDimensions; + dimensions: RadialGaugeDimensions; textMode: Exclude; shape: RadialShape; sparkline?: FieldSparkline; @@ -26,123 +26,137 @@ interface RadialTextProps { nameManualFontSize?: number; } -export function RadialText({ - displayValue, - theme, - dimensions, - textMode, - shape, - sparkline, - alignmentFactors, - valueManualFontSize, - nameManualFontSize, -}: RadialTextProps) { - const styles = useStyles2(getStyles); - const { centerX, centerY, radius, barWidth } = dimensions; +const LINE_HEIGHT_FACTOR = 1.21; +const VALUE_WIDTH_TO_RADIUS_FACTOR = 0.82; +const NAME_TO_HEIGHT_FACTOR = 0.45; +const LARGE_RADIUS_SCALING_DECAY = 0.86; +const MAX_TEXT_WIDTH_DIVISOR = 7; +const MAX_NAME_HEIGHT_DIVISOR = 4; +const VALUE_SPACE_PERCENTAGE = 0.7; +const SPARKLINE_SPACING = 8; +const MIN_VALUE_FONT_SIZE = 1; +const MIN_NAME_FONT_SIZE = 10; +const MIN_UNIT_FONT_SIZE = 6; - if (textMode === 'none') { - return null; - } +export const RadialText = memo( + ({ + displayValue, + theme, + dimensions, + textMode, + shape, + sparkline, + alignmentFactors, + valueManualFontSize, + nameManualFontSize, + }: RadialTextProps) => { + const styles = useStyles2(getStyles); + const { centerX, centerY, radius, barWidth } = dimensions; - const nameToAlignTo = (alignmentFactors ? alignmentFactors.title : displayValue.title) ?? ''; - const valueToAlignTo = formattedValueToString(alignmentFactors ? alignmentFactors : displayValue); + if (textMode === 'none') { + return null; + } - const showValue = textMode === 'value' || textMode === 'value_and_name'; - const showName = textMode === 'name' || textMode === 'value_and_name'; - const maxTextWidth = radius * 2 - barWidth - radius / 7; + const nameToAlignTo = (alignmentFactors ? alignmentFactors.title : displayValue.title) ?? ''; + const valueToAlignTo = formattedValueToString(alignmentFactors ? alignmentFactors : displayValue); - // Not sure where this comes from but svg text is not using body line-height - const lineHeight = 1.21; - const valueWidthToRadiusFactor = 0.82; - const nameToHeightFactor = 0.45; - const largeRadiusScalingDecay = 0.86; + const showValue = textMode === 'value' || textMode === 'value_and_name'; + const showName = textMode === 'name' || textMode === 'value_and_name'; + const maxTextWidth = radius * 2 - barWidth - radius / MAX_TEXT_WIDTH_DIVISOR; - // This pow 0.92 factor is to create a decay so the font size does not become rediculously large for very large panels - let maxValueHeight = valueWidthToRadiusFactor * Math.pow(radius, largeRadiusScalingDecay); - let maxNameHeight = radius / 4; + // This pow 0.92 factor is to create a decay so the font size does not become rediculously large for very large panels + let maxValueHeight = VALUE_WIDTH_TO_RADIUS_FACTOR * Math.pow(radius, LARGE_RADIUS_SCALING_DECAY); + let maxNameHeight = radius / MAX_NAME_HEIGHT_DIVISOR; - if (showValue && showName) { - maxValueHeight = valueWidthToRadiusFactor * Math.pow(radius, largeRadiusScalingDecay); - maxNameHeight = nameToHeightFactor * Math.pow(radius, largeRadiusScalingDecay); - } + if (showValue && showName) { + maxValueHeight = VALUE_WIDTH_TO_RADIUS_FACTOR * Math.pow(radius, LARGE_RADIUS_SCALING_DECAY); + maxNameHeight = NAME_TO_HEIGHT_FACTOR * Math.pow(radius, LARGE_RADIUS_SCALING_DECAY); + } - const valueFontSize = - valueManualFontSize ?? - calculateFontSize( - valueToAlignTo, - maxTextWidth, - maxValueHeight, - lineHeight, - undefined, - theme.typography.body.fontWeight + const valueFontSize = Math.max( + valueManualFontSize ?? + calculateFontSize( + valueToAlignTo, + maxTextWidth, + maxValueHeight, + LINE_HEIGHT_FACTOR, + undefined, + theme.typography.body.fontWeight + ), + MIN_VALUE_FONT_SIZE ); - const nameFontSize = - nameManualFontSize ?? - calculateFontSize( - nameToAlignTo, - maxTextWidth, - maxNameHeight, - lineHeight, - undefined, - theme.typography.body.fontWeight + const nameFontSize = Math.max( + nameManualFontSize ?? + calculateFontSize( + nameToAlignTo, + maxTextWidth, + maxNameHeight, + LINE_HEIGHT_FACTOR, + undefined, + theme.typography.body.fontWeight + ), + MIN_NAME_FONT_SIZE ); - const unitFontSize = Math.max(valueFontSize * 0.7, 5); - const valueHeight = valueFontSize * lineHeight; - const nameHeight = nameFontSize * lineHeight; + const unitFontSize = Math.max(valueFontSize * VALUE_SPACE_PERCENTAGE, MIN_UNIT_FONT_SIZE); + const valueHeight = valueFontSize * LINE_HEIGHT_FACTOR; + const nameHeight = nameFontSize * LINE_HEIGHT_FACTOR; - const valueY = showName ? centerY - nameHeight * 0.3 : centerY; - const nameY = showValue ? valueY + valueHeight * 0.7 : centerY; - const nameColor = showValue ? theme.colors.text.secondary : theme.colors.text.primary; - const suffixShift = (valueFontSize - unitFontSize * 1.2) / 2; + const valueY = showName ? centerY - nameHeight * (1 - VALUE_SPACE_PERCENTAGE) : centerY; + const nameY = showValue ? valueY + valueHeight * VALUE_SPACE_PERCENTAGE : centerY; + const nameColor = showValue ? theme.colors.text.secondary : theme.colors.text.primary; + const suffixShift = (valueFontSize - unitFontSize * LINE_HEIGHT_FACTOR) / 2; - // adjust the text up on gauges and when sparklines are present - let yOffset = 0; - if (shape === 'gauge') { - // we render from the center of the gauge, so move up by half of half of the total height - yOffset -= (valueHeight + nameHeight) / 4; - } - if (sparkline) { - yOffset -= 8; + // adjust the text up on gauges and when sparklines are present + let yOffset = 0; + if (shape === 'gauge') { + // we render from the center of the gauge, so move up by half of half of the total height + yOffset -= (valueHeight + nameHeight) / 4; + } + if (sparkline) { + yOffset -= SPARKLINE_SPACING; + } + + return ( + + {showValue && ( + + {displayValue.prefix ?? ''} + {displayValue.text} + + {displayValue.suffix ?? ''} + + + )} + {showName && ( + + {displayValue.title} + + )} + + ); } +); - return ( - - {showValue && ( - - {displayValue.prefix ?? ''} - {displayValue.text} - - {displayValue.suffix ?? ''} - - - )} - {showName && ( - - {displayValue.title} - - )} - - ); -} +RadialText.displayName = 'RadialText'; -const getStyles = (theme: GrafanaTheme2) => ({ +const getStyles = (_theme: GrafanaTheme2) => ({ text: css({ verticalAlign: 'bottom', }), diff --git a/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx b/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx index 602038ccf10..cb2829934b9 100644 --- a/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx @@ -1,20 +1,22 @@ import { FieldDisplay, Threshold } from '@grafana/data'; import { RadialArcPath } from './RadialArcPath'; -import { RadialColorDefs } from './RadialColorDefs'; -import { GaugeDimensions } from './utils'; +import { GradientStop, RadialGaugeDimensions, RadialShape } from './types'; +import { getFieldConfigMinMax } from './utils'; -export interface Props { - dimensions: GaugeDimensions; +interface ThresholdsBarProps { + dimensions: RadialGaugeDimensions; angleRange: number; startAngle: number; endAngle: number; + shape: RadialShape; fieldDisplay: FieldDisplay; roundedBars?: boolean; glowFilter?: string; - colorDefs: RadialColorDefs; thresholds: Threshold[]; + gradient?: GradientStop[]; } + export function ThresholdsBar({ dimensions, fieldDisplay, @@ -22,19 +24,18 @@ export function ThresholdsBar({ angleRange, roundedBars, glowFilter, - colorDefs, thresholds, -}: Props) { - const fieldConfig = fieldDisplay.field; - const min = fieldConfig.min ?? 0; - const max = fieldConfig.max ?? 100; - + shape, + gradient, +}: ThresholdsBarProps) { const thresholdDimensions = { ...dimensions, barWidth: dimensions.thresholdsBarWidth, radius: dimensions.thresholdsBarRadius, }; + const [min, max] = getFieldConfigMinMax(fieldDisplay); + let currentStart = startAngle; let paths: React.ReactNode[] = []; @@ -48,27 +49,26 @@ export function ThresholdsBar({ valueDeg = 0; } - let lengthDeg = valueDeg - currentStart + startAngle; + const lengthDeg = valueDeg - currentStart + startAngle; + const colorProps = gradient ? { gradient } : { color: threshold.color }; paths.push( ); currentStart += lengthDeg; } - return ( - <> - {paths} - {colorDefs.getDefs()} - - ); + return {paths}; } diff --git a/packages/grafana-ui/src/components/RadialGauge/__snapshots__/colors.test.ts.snap b/packages/grafana-ui/src/components/RadialGauge/__snapshots__/colors.test.ts.snap new file mode 100644 index 00000000000..97b053c2d61 --- /dev/null +++ b/packages/grafana-ui/src/components/RadialGauge/__snapshots__/colors.test.ts.snap @@ -0,0 +1,144 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RadialGauge color utils buildGradientColors should map threshold colors correctly (with baseColor if displayProcessor does not return colors) 1`] = ` +[ + { + "color": "#444444", + "percent": 0, + }, + { + "color": "#FADE2A", + "percent": 0.5, + }, + { + "color": "#F2495C", + "percent": 0.8, + }, + { + "color": "#444444", + "percent": 1, + }, +] +`; + +exports[`RadialGauge color utils buildGradientColors should map threshold colors correctly (with baseColor if displayProcessor does not return colors) 2`] = ` +[ + { + "color": "#FF0000", + "percent": 0, + }, + { + "color": "#FADE2A", + "percent": 0.5, + }, + { + "color": "#F2495C", + "percent": 0.8, + }, + { + "color": "#FF0000", + "percent": 1, + }, +] +`; + +exports[`RadialGauge color utils buildGradientColors should return gradient colors for by-value color mode in dark theme 1`] = ` +[ + { + "color": "#181b1f", + "percent": 0, + }, + { + "color": "#1F60C4", + "percent": 1, + }, +] +`; + +exports[`RadialGauge color utils buildGradientColors should return gradient colors for by-value color mode in light theme 1`] = ` +[ + { + "color": "#ffffff", + "percent": 0, + }, + { + "color": "#1250B0", + "percent": 1, + }, +] +`; + +exports[`RadialGauge color utils buildGradientColors should return gradient colors for continuous color modes 1`] = ` +[ + { + "color": "rgb(0, 32, 81)", + "percent": 0, + }, + { + "color": "rgb(17, 54, 108)", + "percent": 0.125, + }, + { + "color": "rgb(60, 77, 110)", + "percent": 0.25, + }, + { + "color": "rgb(98, 100, 111)", + "percent": 0.375, + }, + { + "color": "rgb(127, 124, 117)", + "percent": 0.5, + }, + { + "color": "rgb(154, 148, 120)", + "percent": 0.625, + }, + { + "color": "rgb(187, 175, 113)", + "percent": 0.75, + }, + { + "color": "rgb(226, 203, 92)", + "percent": 0.875, + }, + { + "color": "rgb(253, 234, 69)", + "percent": 1, + }, +] +`; + +exports[`RadialGauge color utils buildGradientColors should return gradient colors for fixed color mode in dark theme 1`] = ` +[ + { + "color": "#37237a", + "percent": 0, + }, + { + "color": "#a146da", + "percent": 0.75, + }, + { + "color": "#a146da", + "percent": 1, + }, +] +`; + +exports[`RadialGauge color utils buildGradientColors should return gradient colors for fixed color mode in light theme 1`] = ` +[ + { + "color": "#a146da", + "percent": 0, + }, + { + "color": "#3e2b9a", + "percent": 0.75, + }, + { + "color": "#3e2b9a", + "percent": 1, + }, +] +`; diff --git a/packages/grafana-ui/src/components/RadialGauge/__snapshots__/utils.test.ts.snap b/packages/grafana-ui/src/components/RadialGauge/__snapshots__/utils.test.ts.snap new file mode 100644 index 00000000000..db4c1c40882 --- /dev/null +++ b/packages/grafana-ui/src/components/RadialGauge/__snapshots__/utils.test.ts.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for center x and y 1`] = `"M 150 110 A 90 90 0 1 1 149.98429203681178 110.00000137077838 A 10 10 0 0 1 149.98778269529805 130.00000106616096 A 70 70 0 1 0 150 130 A 10 10 0 0 1 150 110 Z"`; + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for half arc 1`] = `"M 100 10 A 90 90 0 0 1 100 190 L 100 170 A 70 70 0 0 0 100 30 L 100 10 Z"`; + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow bar width 1`] = `"M 100 17.5 A 82.5 82.5 0 0 1 100 182.5 L 100 177.5 A 77.5 77.5 0 0 0 100 22.5 L 100 17.5 Z"`; + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow radius 1`] = `"M 100 40 A 60 60 0 0 1 100 160 L 100 140 A 40 40 0 0 0 100 60 L 100 40 Z"`; + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for quarter arc 1`] = `"M 100 10 A 90 90 0 0 1 190 100 L 170 100 A 70 70 0 0 0 100 30 L 100 10 Z"`; + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for rounded bars 1`] = `"M 100 10 A 90 90 0 1 1 10 100.00000000000001 A 10 10 0 0 1 30 100.00000000000001 A 70 70 0 1 0 100 30 A 10 10 0 0 1 100 10 Z"`; + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for three quarter arc 1`] = `"M 100 10 A 90 90 0 1 1 10 100.00000000000001 L 30 100.00000000000001 A 70 70 0 1 0 100 30 L 100 10 Z"`; + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for wide bar width 1`] = `"M 100 -5 A 105 105 0 0 1 100 205 L 100 155 A 55 55 0 0 0 100 45 L 100 -5 Z"`; diff --git a/packages/grafana-ui/src/components/RadialGauge/colors.test.ts b/packages/grafana-ui/src/components/RadialGauge/colors.test.ts new file mode 100644 index 00000000000..321e95bb921 --- /dev/null +++ b/packages/grafana-ui/src/components/RadialGauge/colors.test.ts @@ -0,0 +1,306 @@ +import { defaultsDeep } from 'lodash'; + +import { createTheme, FALLBACK_COLOR, Field, FieldDisplay, FieldType, ThresholdsMode } from '@grafana/data'; +import { FieldColorModeId } from '@grafana/schema'; + +import { + buildGradientColors, + colorAtGradientPercent, + getBarEndcapColors, + getEndpointMarkerColors, + getGradientCss, + getGradientStopsForPercent, +} from './colors'; + +export type DeepPartial = { + [P in keyof T]?: DeepPartial; +}; + +describe('RadialGauge color utils', () => { + describe('buildGradientColors', () => { + const createField = (colorMode: FieldColorModeId): Field => + ({ + type: FieldType.number, + name: 'Test Field', + config: { + color: { + mode: colorMode, + }, + thresholds: { + mode: ThresholdsMode.Absolute, + steps: [ + { value: -Infinity, color: 'green' }, + { value: 50, color: 'yellow' }, + { value: 80, color: 'red' }, + ], + }, + }, + values: [70, 40, 30, 90, 55], + }) satisfies Field; + + const buildFieldDisplay = (field: Field, part = {}): FieldDisplay => + defaultsDeep(part, { + field: field.config, + colIndex: 0, + view: { + getFieldDisplayProcessor: jest.fn(() => jest.fn(() => ({ color: undefined }))), + }, + display: { + numeric: 75, + }, + }); + + it('should return the baseColor if gradient is false-y', () => { + expect( + buildGradientColors(false, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)), '#FF0000') + ).toEqual([ + { color: '#FF0000', percent: 0 }, + { color: '#FF0000', percent: 1 }, + ]); + + expect( + buildGradientColors(undefined, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)), '#FF0000') + ).toEqual([ + { color: '#FF0000', percent: 0 }, + { color: '#FF0000', percent: 1 }, + ]); + }); + + it('uses the fallback color if no baseColor is set', () => { + expect(buildGradientColors(false, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)))).toEqual( + [ + { color: FALLBACK_COLOR, percent: 0 }, + { color: FALLBACK_COLOR, percent: 1 }, + ] + ); + }); + + it('should map threshold colors correctly (with baseColor if displayProcessor does not return colors)', () => { + expect( + buildGradientColors( + true, + createTheme(), + buildFieldDisplay(createField(FieldColorModeId.Thresholds), { + view: { getFieldDisplayProcessor: jest.fn(() => jest.fn(() => ({ color: '#444444' }))) }, + }) + ) + ).toMatchSnapshot(); + }); + + it('should map threshold colors correctly (with baseColor if displayProcessor does not return colors)', () => { + expect( + buildGradientColors(true, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Thresholds)), '#FF0000') + ).toMatchSnapshot(); + }); + + it('should return gradient colors for continuous color modes', () => { + expect( + buildGradientColors( + true, + createTheme(), + buildFieldDisplay(createField(FieldColorModeId.ContinuousCividis)), + '#00FF00' + ) + ).toMatchSnapshot(); + }); + + it.each(['dark', 'light'] as const)('should return gradient colors for by-value color mode in %s theme', (mode) => { + expect( + buildGradientColors( + true, + createTheme({ colors: { mode } }), + buildFieldDisplay(createField(FieldColorModeId.ContinuousBlues)) + ) + ).toMatchSnapshot(); + }); + + it.each(['dark', 'light'] as const)('should return gradient colors for fixed color mode in %s theme', (mode) => { + expect( + buildGradientColors( + true, + createTheme({ colors: { mode } }), + buildFieldDisplay(createField(FieldColorModeId.Fixed)), + '#442299' + ) + ).toMatchSnapshot(); + }); + }); + + describe('colorAtGradientPercent', () => { + it('should calculate the color at a given percent in a gradient of two colors', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#0000ff', percent: 1 }, + ]; + expect(colorAtGradientPercent(gradient, 0).toHexString()).toBe('#ff0000'); + expect(colorAtGradientPercent(gradient, 0.25).toHexString()).toBe('#bf0040'); + expect(colorAtGradientPercent(gradient, 0.5).toHexString()).toBe('#800080'); + expect(colorAtGradientPercent(gradient, 0.75).toHexString()).toBe('#4000bf'); + expect(colorAtGradientPercent(gradient, 1).toHexString()).toBe('#0000ff'); + }); + + it('should calculate the color at a given percent in a gradient of multiple colors', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#0000ff', percent: 1 }, + ]; + expect(colorAtGradientPercent(gradient, 0).toHexString()).toBe('#ff0000'); + expect(colorAtGradientPercent(gradient, 0.25).toHexString()).toBe('#808000'); + expect(colorAtGradientPercent(gradient, 0.5).toHexString()).toBe('#00ff00'); + expect(colorAtGradientPercent(gradient, 0.75).toHexString()).toBe('#008080'); + expect(colorAtGradientPercent(gradient, 1).toHexString()).toBe('#0000ff'); + }); + + it('will still work if unsorted', () => { + const gradient = [ + { color: '#0000ff', percent: 1 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#ff0000', percent: 0 }, + ]; + expect(colorAtGradientPercent(gradient, 0).toHexString()).toBe('#ff0000'); + expect(colorAtGradientPercent(gradient, 0.25).toHexString()).toBe('#808000'); + expect(colorAtGradientPercent(gradient, 0.5).toHexString()).toBe('#00ff00'); + expect(colorAtGradientPercent(gradient, 0.75).toHexString()).toBe('#008080'); + expect(colorAtGradientPercent(gradient, 1).toHexString()).toBe('#0000ff'); + }); + + it('should not throw an error when percent is outside 0-1 range', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#0000ff', percent: 1 }, + ]; + expect(colorAtGradientPercent(gradient, -0.5).toHexString()).toBe('#ff0000'); + expect(colorAtGradientPercent(gradient, 1.5).toHexString()).toBe('#0000ff'); + }); + + it('should throw an error when less than two stops are provided', () => { + expect(() => { + colorAtGradientPercent([], 0.5); + }).toThrow('colorAtGradientPercent requires at least two color stops'); + expect(() => { + colorAtGradientPercent([{ color: '#ff0000', percent: 0 }], 0.5); + }).toThrow('colorAtGradientPercent requires at least two color stops'); + }); + }); + + describe('getBarEndcapColors', () => { + it('should return the first and last colors in the gradient', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#0000ff', percent: 1 }, + ]; + const [startColor, endColor] = getBarEndcapColors(gradient); + expect(startColor).toBe('#ff0000'); + expect(endColor).toBe('#0000ff'); + }); + + it('should return the correct end color based on percent', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#0000ff', percent: 1 }, + ]; + const [startColor, endColor] = getBarEndcapColors(gradient, 0.25); + expect(startColor).toBe('#ff0000'); + expect(endColor).toBe('#808000'); + }); + + it('should handle gradients with only one colors', () => { + const gradient = [{ color: '#ff0000', percent: 0 }]; + const [startColor, endColor] = getBarEndcapColors(gradient); + expect(startColor).toBe('#ff0000'); + expect(endColor).toBe('#ff0000'); + }); + + it('should throw an error when no colors are provided', () => { + expect(() => { + getBarEndcapColors([]); + }).toThrow('getBarEndcapColors requires at least one color stop'); + }); + }); + + describe('getGradientCss', () => { + it('should return conic-gradient CSS for circle shape', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#0000ff', percent: 1 }, + ]; + const css = getGradientCss(gradient, 'circle'); + expect(css).toBe('conic-gradient(from 0deg, #ff0000 0.00%, #00ff00 50.00%, #0000ff 100.00%)'); + }); + + it('should return linear-gradient CSS for arc shape', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#0000ff', percent: 1 }, + ]; + const css = getGradientCss(gradient, 'gauge'); + expect(css).toBe('linear-gradient(90deg, #ff0000 0.00%, #00ff00 50.00%, #0000ff 100.00%)'); + }); + }); + + describe('getEndpointMarkerColors', () => { + it('should return contrasting guide dot colors based on the gradient endpoints and percent', () => { + const gradient = [ + { color: '#000000', percent: 0 }, + { color: '#ffffff', percent: 0.5 }, + { color: '#ffffff', percent: 1 }, + ]; + const [startDotColor, endDotColor] = getEndpointMarkerColors(gradient, 0.35); + expect(startDotColor).toBe('#fbfbfb'); + expect(endDotColor).toBe('#111217'); + }); + }); + + describe('getGradientStopsForPercent', () => { + it('should return the correct gradient stops for a given percent', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#0000ff', percent: 1 }, + ]; + const [left, right] = getGradientStopsForPercent(gradient, 0.25); + expect(left).toEqual({ color: '#ff0000', percent: 0 }); + expect(right).toEqual({ color: '#00ff00', percent: 0.5 }); + }); + + it('should handle edge cases where percent is at the boundaries', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#0000ff', percent: 1 }, + ]; + let [left, right] = getGradientStopsForPercent(gradient, 0); + expect(left).toEqual({ color: '#ff0000', percent: 0 }); + expect(right).toEqual({ color: '#ff0000', percent: 0 }); + + [left, right] = getGradientStopsForPercent(gradient, 1); + expect(left).toEqual({ color: '#0000ff', percent: 1 }); + expect(right).toEqual({ color: '#0000ff', percent: 1 }); + }); + + it('should return the same stop if there is one that is equal to the percentage', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#0000ff', percent: 1 }, + ]; + + let [left, right] = getGradientStopsForPercent(gradient, 0); + expect(left).toEqual({ color: '#ff0000', percent: 0 }); + expect(right).toEqual({ color: '#ff0000', percent: 0 }); + + [left, right] = getGradientStopsForPercent(gradient, 0.5); + expect(left).toEqual({ color: '#00ff00', percent: 0.5 }); + expect(right).toEqual({ color: '#00ff00', percent: 0.5 }); + + [left, right] = getGradientStopsForPercent(gradient, 1); + expect(left).toEqual({ color: '#0000ff', percent: 1 }); + expect(right).toEqual({ color: '#0000ff', percent: 1 }); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/RadialGauge/colors.ts b/packages/grafana-ui/src/components/RadialGauge/colors.ts new file mode 100644 index 00000000000..61160a9f826 --- /dev/null +++ b/packages/grafana-ui/src/components/RadialGauge/colors.ts @@ -0,0 +1,195 @@ +import tinycolor from 'tinycolor2'; + +import { colorManipulator, FALLBACK_COLOR, FieldDisplay, getFieldColorMode, GrafanaTheme2 } from '@grafana/data'; +import { FieldColorModeId } from '@grafana/schema'; + +import { GradientStop, RadialShape } from './types'; +import { getFieldConfigMinMax, getFieldDisplayProcessor, getValuePercentageForValue } from './utils'; + +export function buildGradientColors( + gradient = false, + theme: GrafanaTheme2, + fieldDisplay: FieldDisplay, + baseColor = fieldDisplay.display.color ?? FALLBACK_COLOR +): GradientStop[] { + if (!gradient) { + return [ + { color: baseColor, percent: 0 }, + { color: baseColor, percent: 1 }, + ]; + } + + const colorMode = getFieldColorMode(fieldDisplay.field.color?.mode); + + // thresholds get special handling + if (colorMode.id === FieldColorModeId.Thresholds) { + const displayProcessor = getFieldDisplayProcessor(fieldDisplay); + const [min, max] = getFieldConfigMinMax(fieldDisplay); + const thresholds = fieldDisplay.field.thresholds?.steps ?? []; + + const result: Array<{ color: string; percent: number }> = [ + { color: displayProcessor(min).color ?? baseColor, percent: 0 }, + ]; + + for (const threshold of thresholds) { + if (threshold.value > min && threshold.value < max) { + const percent = (threshold.value - min) / (max - min); + result.push({ color: theme.visualization.getColorByName(threshold.color), percent }); + } + } + + result.push({ color: displayProcessor(max).color ?? baseColor, percent: 1 }); + + return result; + } + + // Handle continuous color modes before other by-value modes + if (colorMode.isContinuous && colorMode.getColors) { + const colors = colorMode.getColors(theme); + return colors.map((color, idx) => ({ color, percent: idx / (colors.length - 1) })); + } + + // For value-based colors, we want to stay more true to the specific color, + // so a radial gradient that adds a bit of light and shade works best + if (colorMode.isByValue) { + const darkerColor = tinycolor(baseColor).darken(5); + const lighterColor = tinycolor(baseColor).spin(20).lighten(10); + + const color1 = theme.isDark ? lighterColor : darkerColor; + const color2 = theme.isDark ? darkerColor : lighterColor; + + return [ + { color: color1.toString(), percent: 0 }, + { color: color2.toString(), percent: 0.6 }, + { color: color2.toString(), percent: 1 }, + ]; + } + + // For fixed / palette based color scales we can create a more hue and light + // based linear gradient that we rotate with the value + const darkerColor = tinycolor(baseColor) + .spin(-20) + .darken(theme.isDark ? 15 : 5); + const lighterColor = tinycolor(baseColor).saturate(20).spin(20).brighten(10).lighten(10); + + const underlyingGradient = [ + { color: theme.isDark ? darkerColor.toString() : lighterColor.toString(), percent: 0 }, + { color: theme.isDark ? lighterColor.toString() : darkerColor.toString(), percent: 1 }, + ]; + + // rotate the gradient so that the highest contrasting point is the value, depending on theme. + const valuePercent = getValuePercentageForValue(fieldDisplay); + const startColor = theme.isDark + ? colorAtGradientPercent(underlyingGradient, 1 - valuePercent).toHexString() + : underlyingGradient[0].color; + const endColor = theme.isDark + ? underlyingGradient[1].color + : colorAtGradientPercent(underlyingGradient, valuePercent).toHexString(); + return [ + { color: startColor, percent: 0 }, + { color: endColor, percent: valuePercent }, + { color: endColor, percent: 1 }, + ]; +} + +/** + * get the relevant gradient stops surrounding a given percentage. could be same stop if the + * percent matches a stop exactly. + * + * @param sortedGradientStops - gradient stops sorted by percent + * @param percent - percentage 0..1 + * @returns {[GradientStop, GradientStop]} - the two gradient stops surrounding the given percentage + */ +export function getGradientStopsForPercent( + sortedGradientStops: GradientStop[], + percent: number +): [GradientStop, GradientStop] { + if (percent <= 0) { + return [sortedGradientStops[0], sortedGradientStops[0]]; + } + if (percent >= 1) { + const last = sortedGradientStops.length - 1; + return [sortedGradientStops[last], sortedGradientStops[last]]; + } + + // find surrounding stops using binary search + let lo = 0; + let hi = sortedGradientStops.length - 1; + while (lo + 1 < hi) { + const mid = (lo + hi) >> 1; + if (percent === sortedGradientStops[mid].percent) { + return [sortedGradientStops[mid], sortedGradientStops[mid]]; + } + + if (percent < sortedGradientStops[mid].percent) { + hi = mid; + } else { + lo = mid; + } + } + return [sortedGradientStops[lo], sortedGradientStops[hi]]; +} + +/** + * @alpha - perhaps this should go in colorManipulator.ts + * Given color stops (each with a color and percentage 0..1) returns the color at a given percentage. + * Uses tinycolor.mix for interpolation. + * @params stops - array of color stops (percentages 0..1) + * @params percent - percentage 0..1 + * @returns color at the given percentage + */ +export function colorAtGradientPercent(stops: GradientStop[], percent: number): tinycolor.Instance { + if (!stops || stops.length < 2) { + throw new Error('colorAtGradientPercent requires at least two color stops'); + } + + const sorted = stops + .map((s: GradientStop): GradientStop => ({ color: s.color, percent: Math.min(Math.max(0, s.percent), 1) })) + .sort((a: GradientStop, b: GradientStop) => a.percent - b.percent); + + const [left, right] = getGradientStopsForPercent(sorted, percent); + const range = right.percent - left.percent; + const t = range === 0 ? 0 : (percent - left.percent) / range; // 0..1 + return tinycolor.mix(left.color, right.color, t * 100); +} + +export function getBarEndcapColors(gradientStops: GradientStop[], percent = 1): [string, string] { + if (gradientStops.length === 0) { + throw new Error('getBarEndcapColors requires at least one color stop'); + } + + const startColor = gradientStops[0].color; + let endColor = gradientStops[gradientStops.length - 1].color; + + // if we have a percentageFilled, use it to get a the correct end color based on where the bar terminates + if (gradientStops.length >= 2) { + const endColorByPercentage = colorAtGradientPercent(gradientStops, percent); + endColor = + endColorByPercentage.getAlpha() === 1 ? endColorByPercentage.toHexString() : endColorByPercentage.toHex8String(); + } + return [startColor, endColor]; +} + +export function getGradientCss(gradientStops: GradientStop[], shape: RadialShape): string { + const colorStrings = gradientStops.map((stop) => `${stop.color} ${(stop.percent * 100).toFixed(2)}%`); + if (shape === 'circle') { + return `conic-gradient(from 0deg, ${colorStrings.join(', ')})`; + } + return `linear-gradient(90deg, ${colorStrings.join(', ')})`; +} + +// the theme does not make the full palette available to us, and we +// don't want transparent colors which our grays usually have. +const GRAY_05 = '#111217'; +const GRAY_90 = '#fbfbfb'; +const CONTRAST_THRESHOLD_MAX = 4.5; +const getGuideDotColor = (color: string): string => { + const darkColor = GRAY_05; + const lightColor = GRAY_90; + return colorManipulator.getContrastRatio(darkColor, color) >= CONTRAST_THRESHOLD_MAX ? darkColor : lightColor; +}; + +export function getEndpointMarkerColors(gradientStops: GradientStop[], percent = 1): [string, string] { + const [startColor, endColor] = getBarEndcapColors(gradientStops, percent); + return [getGuideDotColor(startColor), getGuideDotColor(endColor)]; +} diff --git a/packages/grafana-ui/src/components/RadialGauge/effects.tsx b/packages/grafana-ui/src/components/RadialGauge/effects.tsx index 354a68a25ba..c48307f177e 100644 --- a/packages/grafana-ui/src/components/RadialGauge/effects.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/effects.tsx @@ -1,15 +1,18 @@ import { GrafanaTheme2 } from '@grafana/data'; -import { GaugeDimensions } from './utils'; +import { RadialGaugeDimensions } from './types'; export interface GlowGradientProps { id: string; barWidth: number; } +const MIN_GLOW_SIZE = 0.75; +const GLOW_FACTOR = 0.08; + export function GlowGradient({ id, barWidth }: GlowGradientProps) { // 0.75 is the minimum glow size, and it scales with bar width - const glowSize = 0.75 + barWidth * 0.08; + const glowSize = MIN_GLOW_SIZE + barWidth * GLOW_FACTOR; return ( @@ -22,56 +25,19 @@ export function GlowGradient({ id, barWidth }: GlowGradientProps) { ); } -export function SpotlightGradient({ - id, - dimensions, - roundedBars, - angle, - theme, -}: { - id: string; - dimensions: GaugeDimensions; - angle: number; - roundedBars: boolean; - theme: GrafanaTheme2; -}) { - const angleRadian = ((angle - 90) * Math.PI) / 180; - - let x1 = dimensions.centerX + dimensions.radius * Math.cos(angleRadian - 0.2); - let y1 = dimensions.centerY + dimensions.radius * Math.sin(angleRadian - 0.2); - let x2 = dimensions.centerX + dimensions.radius * Math.cos(angleRadian); - let y2 = dimensions.centerY + dimensions.radius * Math.sin(angleRadian); - - if (theme.isLight) { - return ( - - - - - - ); - } - - return ( - - - - {roundedBars && } - - ); -} +const CENTER_GLOW_OPACITY = 0.15; export function CenterGlowGradient({ gaugeId, color }: { gaugeId: string; color: string }) { return ( - - + + ); } export interface CenterGlowProps { - dimensions: GaugeDimensions; + dimensions: RadialGaugeDimensions; gaugeId: string; color?: string; } @@ -82,8 +48,8 @@ export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps return ( <> - - + + @@ -93,3 +59,36 @@ export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps ); } + +export function SpotlightGradient({ + id, + dimensions, + roundedBars, + angle, + theme, +}: { + id: string; + dimensions: RadialGaugeDimensions; + angle: number; + roundedBars: boolean; + theme: GrafanaTheme2; +}) { + if (theme.isLight) { + return null; + } + + const angleRadian = ((angle - 90) * Math.PI) / 180; + + let x1 = dimensions.centerX + dimensions.radius * Math.cos(angleRadian - 0.2); + let y1 = dimensions.centerY + dimensions.radius * Math.sin(angleRadian - 0.2); + let x2 = dimensions.centerX + dimensions.radius * Math.cos(angleRadian); + let y2 = dimensions.centerY + dimensions.radius * Math.sin(angleRadian); + + return ( + + + + {roundedBars && } + + ); +} diff --git a/packages/grafana-ui/src/components/RadialGauge/types.ts b/packages/grafana-ui/src/components/RadialGauge/types.ts new file mode 100644 index 00000000000..cc233dd524c --- /dev/null +++ b/packages/grafana-ui/src/components/RadialGauge/types.ts @@ -0,0 +1,25 @@ +export type RadialTextMode = 'auto' | 'value_and_name' | 'value' | 'name' | 'none'; +export type RadialShape = 'circle' | 'gauge'; + +export interface RadialGaugeDimensions { + margin: number; + radius: number; + centerX: number; + centerY: number; + barWidth: number; + endAngle?: number; + barIndex: number; + thresholdsBarRadius: number; + thresholdsBarWidth: number; + thresholdsBarSpacing: number; + scaleLabelsFontSize: number; + scaleLabelsSpacing: number; + scaleLabelsRadius: number; + gaugeBottomY: number; +} + +/** @alpha - perhaps this should go in @grafana/data */ +export interface GradientStop { + color: string; + percent: number; +} diff --git a/packages/grafana-ui/src/components/RadialGauge/utils.test.ts b/packages/grafana-ui/src/components/RadialGauge/utils.test.ts index 5e3f34d62cd..b9b2e4ad8f3 100644 --- a/packages/grafana-ui/src/components/RadialGauge/utils.test.ts +++ b/packages/grafana-ui/src/components/RadialGauge/utils.test.ts @@ -1,24 +1,111 @@ -import { FieldDisplay } from '@grafana/data'; +import { DataFrameView, FieldDisplay } from '@grafana/data'; import type { RadialGaugeProps } from './RadialGauge'; -import { calculateDimensions, toRad, getValueAngleForValue } from './utils'; +import { RadialGaugeDimensions } from './types'; +import { + calculateDimensions, + toRad, + getValueAngleForValue, + drawRadialArcPath, + getFieldConfigMinMax, + getFieldDisplayProcessor, + getAngleBetweenSegments, + getOptimalSegmentCount, +} from './utils'; describe('RadialGauge utils', () => { - function calc(overrides: Partial = {}) { - return calculateDimensions( - overrides.width ?? 200, - overrides.height ?? 200, - overrides.shape === 'gauge' ? 110 : 360, - overrides.glowBar ?? false, - overrides.roundedBars ?? false, - overrides.barWidthFactor ?? 0.4, - overrides.barIndex ?? 0, - overrides.thresholdsBar ?? false, - overrides.showScaleLabels ?? false - ); - } + describe('getFieldDisplayProcessor', () => { + it('should return display processor from view when available', () => { + const mockProcessor = jest.fn(); + const mockView = { + getFieldDisplayProcessor: jest.fn().mockReturnValue(mockProcessor), + } as unknown as DataFrameView; + + const fieldDisplay: FieldDisplay = { + display: { numeric: 50, text: '50', color: 'blue' }, + field: {}, + view: mockView, + colIndex: 0, + rowIndex: 0, + name: 'test', + getLinks: () => [], + hasLinks: false, + }; + + const dp = getFieldDisplayProcessor(fieldDisplay); + expect(dp).toBe(mockProcessor); + expect(mockView.getFieldDisplayProcessor).toHaveBeenCalledWith(0); + }); + + it('should return default display processor when view is not available', () => { + const fieldDisplay: FieldDisplay = { + display: { numeric: 50, text: '50', color: 'blue' }, + field: {}, + view: undefined, + colIndex: 0, + rowIndex: 0, + name: 'test', + getLinks: () => [], + hasLinks: false, + }; + + const dp = getFieldDisplayProcessor(fieldDisplay); + expect(dp).toBeDefined(); + expect(typeof dp).toBe('function'); + }); + }); + + describe('getFieldConfigMinMax', () => { + it('should return min and max from field config when defined', () => { + const fieldDisplay: FieldDisplay = { + display: { numeric: 50, text: '50', color: 'blue' }, + field: { min: 10, max: 90 }, + view: undefined, + colIndex: 0, + rowIndex: 0, + name: 'test', + getLinks: () => [], + hasLinks: false, + }; + + const [min, max] = getFieldConfigMinMax(fieldDisplay); + expect(min).toBe(10); + expect(max).toBe(90); + }); + + it('should return default min and max when not defined in field config', () => { + const fieldDisplay: FieldDisplay = { + display: { numeric: 50, text: '50', color: 'blue' }, + field: {}, + view: undefined, + colIndex: 0, + rowIndex: 0, + name: 'test', + getLinks: () => [], + hasLinks: false, + }; + + const [min, max] = getFieldConfigMinMax(fieldDisplay); + expect(min).toBe(0); + expect(max).toBe(100); + }); + }); describe('calculateDimensions', () => { + function calc(overrides: Partial = {}) { + return calculateDimensions( + overrides.width ?? 200, + overrides.height ?? 200, + overrides.shape === 'gauge' ? 110 : 360, + overrides.glowBar ?? false, + overrides.roundedBars ?? false, + overrides.barWidthFactor ?? 0.4, + overrides.barIndex ?? 0, + overrides.thresholdsBar ?? false, + overrides.showScaleLabels ?? false + ); + } + it('should calculate basic dimensions for a square gauge', () => { const result = calc(); @@ -194,4 +281,84 @@ describe('RadialGauge utils', () => { expect(result.angle).toBe(240); }); }); + + describe('drawRadialArcPath', () => { + const defaultDims: RadialGaugeDimensions = Object.freeze({ + centerX: 100, + centerY: 100, + radius: 80, + barWidth: 20, + margin: 0, + barIndex: 0, + thresholdsBarWidth: 0, + thresholdsBarSpacing: 0, + thresholdsBarRadius: 0, + scaleLabelsFontSize: 0, + scaleLabelsSpacing: 0, + scaleLabelsRadius: 0, + gaugeBottomY: 0, + }); + + it.each([ + { description: 'quarter arc', startAngle: 0, endAngle: 90 }, + { description: 'half arc', startAngle: 0, endAngle: 180 }, + { description: 'three quarter arc', startAngle: 0, endAngle: 270 }, + { description: 'rounded bars', startAngle: 0, endAngle: 270, roundedBars: true }, + { description: 'wide bar width', startAngle: 0, endAngle: 180, dimensions: { barWidth: 50 } }, + { description: 'narrow bar width', startAngle: 0, endAngle: 180, dimensions: { barWidth: 5 } }, + { description: 'narrow radius', startAngle: 0, endAngle: 180, dimensions: { radius: 50 } }, + { + description: 'center x and y', + startAngle: 0, + endAngle: 360, + roundedBars: true, + dimensions: { centerX: 150, centerY: 200 }, + }, + ])(`should draw correct path for $description`, ({ startAngle, endAngle, dimensions, roundedBars }) => { + const path = drawRadialArcPath(startAngle, endAngle, { ...defaultDims, ...dimensions }, roundedBars); + expect(path).toMatchSnapshot(); + }); + + describe('edge cases', () => { + it('should adjust 360deg or greater arcs to avoid SVG rendering issues', () => { + expect(drawRadialArcPath(0, 360, defaultDims)).toEqual(drawRadialArcPath(0, 359.99, defaultDims)); + expect(drawRadialArcPath(0, 380, defaultDims)).toEqual(drawRadialArcPath(0, 380, defaultDims)); + }); + + it('should return empty string if inner radius collapses to zero or below', () => { + const smallRadiusDims = { ...defaultDims, radius: 5, barWidth: 20 }; + expect(drawRadialArcPath(0, 180, smallRadiusDims)).toBe(''); + }); + }); + }); + + describe('getAngleBetweenSegments', () => { + it('should calculate angle between segments based on spacing and count', () => { + expect(getAngleBetweenSegments(2, 10, 360)).toBe(48); + expect(getAngleBetweenSegments(5, 15, 180)).toBe(40); + }); + }); + + describe('getOptimalSegmentCount', () => { + it('should adjust segment count based on dimensions and spacing', () => { + const dimensions: RadialGaugeDimensions = { + centerX: 100, + centerY: 100, + radius: 80, + barWidth: 20, + margin: 0, + barIndex: 0, + thresholdsBarWidth: 0, + thresholdsBarSpacing: 0, + thresholdsBarRadius: 0, + scaleLabelsFontSize: 0, + scaleLabelsSpacing: 0, + scaleLabelsRadius: 0, + gaugeBottomY: 0, + }; + + expect(getOptimalSegmentCount(dimensions, 2, 10, 360)).toBe(8); + expect(getOptimalSegmentCount(dimensions, 1, 5, 360)).toBe(5); + }); + }); }); diff --git a/packages/grafana-ui/src/components/RadialGauge/utils.ts b/packages/grafana-ui/src/components/RadialGauge/utils.ts index 44f767d89b2..e26cf5eed2a 100644 --- a/packages/grafana-ui/src/components/RadialGauge/utils.ts +++ b/packages/grafana-ui/src/components/RadialGauge/utils.ts @@ -1,11 +1,38 @@ -import { FieldDisplay } from '@grafana/data'; +import { FieldDisplay, getDisplayProcessor } from '@grafana/data'; -export function getValueAngleForValue(fieldDisplay: FieldDisplay, startAngle: number, endAngle: number) { - const angleRange = (360 % (startAngle === 0 ? 1 : startAngle)) + endAngle; +import { RadialGaugeDimensions } from './types'; + +export function getFieldDisplayProcessor(displayValue: FieldDisplay) { + if (displayValue.view && displayValue.colIndex != null) { + const dp = displayValue.view.getFieldDisplayProcessor(displayValue.colIndex); + if (dp) { + return dp; + } + } + + return getDisplayProcessor(); +} + +export function getFieldConfigMinMax(fieldDisplay: FieldDisplay) { const min = fieldDisplay.field.min ?? 0; const max = fieldDisplay.field.max ?? 100; + return [min, max]; +} - let angle = ((fieldDisplay.display.numeric - min) / (max - min)) * angleRange; +export function getValuePercentageForValue(fieldDisplay: FieldDisplay, value = fieldDisplay.display.numeric) { + const [min, max] = getFieldConfigMinMax(fieldDisplay); + return (value - min) / (max - min); +} + +export function getValueAngleForValue( + fieldDisplay: FieldDisplay, + startAngle: number, + endAngle: number, + value = fieldDisplay.display.numeric +) { + const angleRange = (360 % (startAngle === 0 ? 1 : startAngle)) + endAngle; + + let angle = getValuePercentageForValue(fieldDisplay, value) * angleRange; if (angle > angleRange) { angle = angleRange; @@ -26,24 +53,19 @@ export function toRad(angle: number) { return ((angle - 90) * Math.PI) / 180; } -export interface GaugeDimensions { - margin: number; - radius: number; - centerX: number; - centerY: number; - barWidth: number; - endAngle?: number; - barIndex: number; - thresholdsBarRadius: number; - thresholdsBarWidth: number; - thresholdsBarSpacing: number; - showScaleLabels?: boolean; - scaleLabelsFontSize: number; - scaleLabelsSpacing: number; - scaleLabelsRadius: number; - gaugeBottomY: number; -} - +/** + * returns the calculated dimensions for the radial gauge + * @param width + * @param height + * @param endAngle + * @param glow + * @param roundedBars + * @param barWidthFactor + * @param barIndex + * @param thresholdBar + * @param showScaleLabels + * @returns {RadialGaugeDimensions} + */ export function calculateDimensions( width: number, height: number, @@ -54,7 +76,7 @@ export function calculateDimensions( barIndex: number, thresholdBar?: boolean, showScaleLabels?: boolean -): GaugeDimensions { +): RadialGaugeDimensions { const yMaxAngle = endAngle > 180 ? 180 : endAngle; let margin = 0; @@ -97,6 +119,7 @@ export function calculateDimensions( maxRadiusW -= labelsSize; maxRadiusH -= labelsSize; + // FIXME: needs coverage // For gauges the max label needs a bit more vertical space so that it does not get clipped if (maxRadiusIsLimitedByHeight && endAngle < 180) { const amount = outerRadius * 0.07; @@ -155,3 +178,105 @@ export function toCartesian(centerX: number, centerY: number, radius: number, an y: centerY + radius * Math.sin(radian), }; } + +export function drawRadialArcPath( + startAngle: number, + endAngle: number, + dimensions: RadialGaugeDimensions, + roundedBars?: boolean +): string { + const { radius, centerX, centerY, barWidth } = dimensions; + + // For some reason a 100% full arc cannot be rendered + if (endAngle >= 360) { + endAngle = 359.99; + } + + const startRadians = toRad(startAngle); + const endRadians = toRad(startAngle + endAngle); + + const largeArc = endAngle > 180 ? 1 : 0; + + const outerR = radius + barWidth / 2; + const innerR = Math.max(0, radius - barWidth / 2); + if (innerR <= 0) { + return ''; // cannot draw arc with 0 inner radius + } + + // get points for both an inner and outer arc. we draw + // the arc entirely with a path's fill instead of using stroke + // so that it can be used as a clip-path. + const ox1 = centerX + outerR * Math.cos(startRadians); + const oy1 = centerY + outerR * Math.sin(startRadians); + const ox2 = centerX + outerR * Math.cos(endRadians); + const oy2 = centerY + outerR * Math.sin(endRadians); + + const ix1 = centerX + innerR * Math.cos(startRadians); + const iy1 = centerY + innerR * Math.sin(startRadians); + const ix2 = centerX + innerR * Math.cos(endRadians); + const iy2 = centerY + innerR * Math.sin(endRadians); + + // calculate the cap width in case we're drawing rounded bars + const capR = barWidth / 2; + + const pathParts = [ + // start at outer start + 'M', + ox1, + oy1, + // outer arc from start to end (clockwise) + 'A', + outerR, + outerR, + 0, + largeArc, + 1, + ox2, + oy2, + ]; + + if (roundedBars) { + // rounded end cap: small arc connecting outer end to inner end + pathParts.push('A', capR, capR, 0, 0, 1, ix2, iy2); + } else { + // straight line to inner end (square butt) + pathParts.push('L', ix2, iy2); + } + + // inner arc from end back to start (counter-clockwise) + pathParts.push('A', innerR, innerR, 0, largeArc, 0, ix1, iy1); + + if (roundedBars) { + // rounded start cap: small arc connecting inner start back to outer start + pathParts.push('A', capR, capR, 0, 0, 1, ox1, oy1); + } else { + // straight line back to outer start (square butt) + pathParts.push('L', ox1, oy1); + } + + pathParts.push('Z'); + + return pathParts.join(' '); +} + +export function getAngleBetweenSegments(segmentSpacing: number, segmentCount: number, range: number) { + // Max spacing is 8 degrees between segments + // Changing this constant could be considered a breaking change + const maxAngleBetweenSegments = Math.max(range / 1.5 / segmentCount, 2); + return segmentSpacing * maxAngleBetweenSegments; +} + +export function getOptimalSegmentCount( + dimensions: RadialGaugeDimensions, + segmentSpacing: number, + segmentCount: number, + range: number +) { + const angleBetweenSegments = getAngleBetweenSegments(segmentSpacing, segmentCount, range); + + const innerRadius = dimensions.radius - dimensions.barWidth / 2; + const circumference = Math.PI * innerRadius * 2 * (range / 360); + const maxSegments = Math.floor(circumference / (angleBetweenSegments + 3)); + + return Math.min(maxSegments, segmentCount); +} diff --git a/public/app/plugins/panel/radialbar/EffectsEditor.tsx b/public/app/plugins/panel/radialbar/EffectsEditor.tsx index d7a0f03cbc7..a3c26beca90 100644 --- a/public/app/plugins/panel/radialbar/EffectsEditor.tsx +++ b/public/app/plugins/panel/radialbar/EffectsEditor.tsx @@ -44,11 +44,6 @@ export function EffectsEditor(props: StandardEditorProps) { value={!!props.value?.gradient} onChange={(e) => props.onChange({ ...props.value, gradient: e.currentTarget.checked })} /> - props.onChange({ ...props.value, rounded: e.currentTarget.checked })} - /> ) { value={!!props.value?.centerGlow} onChange={(e) => props.onChange({ ...props.value, centerGlow: e.currentTarget.checked })} /> - props.onChange({ ...props.value, spotlight: e.currentTarget.checked })} - /> ); } diff --git a/public/app/plugins/panel/radialbar/RadialBarPanel.tsx b/public/app/plugins/panel/radialbar/RadialBarPanel.tsx index 86235a3bf68..03232406463 100644 --- a/public/app/plugins/panel/radialbar/RadialBarPanel.tsx +++ b/public/app/plugins/panel/radialbar/RadialBarPanel.tsx @@ -37,11 +37,10 @@ export function RadialBarPanel({ width={width} height={height} barWidthFactor={options.barWidthFactor} - gradient={options.effects?.gradient ? 'auto' : 'none'} - spotlight={options.effects?.spotlight} + gradient={options.effects?.gradient} glowBar={options.effects?.barGlow} glowCenter={options.effects?.centerGlow} - roundedBars={options.effects?.rounded} + roundedBars={options.barShape === 'rounded'} vizCount={valueProps.count} shape={options.shape} segmentCount={options.segmentCount} @@ -51,6 +50,7 @@ export function RadialBarPanel({ alignmentFactors={valueProps.alignmentFactors} valueManualFontSize={options.text?.valueSize} nameManualFontSize={options.text?.titleSize} + endpointMarker={options.endpointMarker !== 'none' ? options.endpointMarker : undefined} onClick={menuProps.openMenu} /> ); diff --git a/public/app/plugins/panel/radialbar/module.tsx b/public/app/plugins/panel/radialbar/module.tsx index 12aff4966f7..3dfcb3e3d5f 100644 --- a/public/app/plugins/panel/radialbar/module.tsx +++ b/public/app/plugins/panel/radialbar/module.tsx @@ -69,6 +69,36 @@ export const plugin = new PanelPlugin(RadialBarPanel) }, }); + builder.addRadio({ + path: 'barShape', + name: t('radialbar.config.bar-shape', 'Bar Style'), + category, + defaultValue: defaultOptions.barShape, + settings: { + options: [ + { value: 'flat', label: t('radialbar.config.bar-shape-flat', 'Flat') }, + { value: 'rounded', label: t('radialbar.config.bar-shape-rounded', 'Rounded') }, + ], + }, + showIf: (options) => options.segmentCount === 1, + }); + + builder.addRadio({ + path: 'endpointMarker', + name: t('radialbar.config.endpoint-marker', 'Endpoint marker'), + description: t('radialbar.config.endpoint-marker-description', 'Glow is only supported in dark mode'), + category, + defaultValue: defaultOptions.endpointMarker, + settings: { + options: [ + { value: 'point', label: t('radialbar.config.endpoint-marker-point', 'Point') }, + { value: 'glow', label: t('radialbar.config.endpoint-marker-glow', 'Glow') }, + { value: 'none', label: t('radialbar.config.endpoint-marker-none', 'None') }, + ], + }, + showIf: (options) => options.barShape === 'rounded' && options.segmentCount === 1, + }); + builder.addBooleanSwitch({ path: 'sparkline', name: t('radialbar.config.sparkline', 'Show sparkline'), diff --git a/public/app/plugins/panel/radialbar/panelcfg.cue b/public/app/plugins/panel/radialbar/panelcfg.cue index a37982dcfe1..c959512c0b8 100644 --- a/public/app/plugins/panel/radialbar/panelcfg.cue +++ b/public/app/plugins/panel/radialbar/panelcfg.cue @@ -27,10 +27,8 @@ composableKinds: PanelCfg: { schema: { GaugePanelEffects: { barGlow?: bool | *false - spotlight?: bool | *false - rounded?: bool | *false centerGlow?: bool | *false - gradient?: bool | *true + gradient?: bool | *true } @cuetsy(kind="interface") Options: { @@ -42,6 +40,8 @@ composableKinds: PanelCfg: { sparkline?: bool | *true shape: "circle" | *"gauge" barWidthFactor: number | *0.5 + barShape: "flat" | "rounded" | *"flat" + endpointMarker?: "point" | "glow" | "none" | *"point" effects: GaugePanelEffects | *{} } @cuetsy(kind="interface") } diff --git a/public/app/plugins/panel/radialbar/panelcfg.gen.ts b/public/app/plugins/panel/radialbar/panelcfg.gen.ts index 24915c62ef1..e050a044f77 100644 --- a/public/app/plugins/panel/radialbar/panelcfg.gen.ts +++ b/public/app/plugins/panel/radialbar/panelcfg.gen.ts @@ -14,21 +14,19 @@ export interface GaugePanelEffects { barGlow?: boolean; centerGlow?: boolean; gradient?: boolean; - rounded?: boolean; - spotlight?: boolean; } export const defaultGaugePanelEffects: Partial = { barGlow: false, centerGlow: false, gradient: true, - rounded: false, - spotlight: false, }; export interface Options extends common.SingleStatBaseOptions { + barShape: ('flat' | 'rounded'); barWidthFactor: number; effects: GaugePanelEffects; + endpointMarker?: ('point' | 'glow' | 'none'); segmentCount: number; segmentSpacing: number; shape: ('circle' | 'gauge'); @@ -38,8 +36,10 @@ export interface Options extends common.SingleStatBaseOptions { } export const defaultOptions: Partial = { + barShape: 'flat', barWidthFactor: 0.5, effects: {}, + endpointMarker: 'point', segmentCount: 1, segmentSpacing: 0.3, shape: 'gauge', diff --git a/public/app/plugins/panel/radialbar/suggestions.ts b/public/app/plugins/panel/radialbar/suggestions.ts index 00896ae8458..eab5334ef40 100644 --- a/public/app/plugins/panel/radialbar/suggestions.ts +++ b/public/app/plugins/panel/radialbar/suggestions.ts @@ -18,19 +18,6 @@ const withDefaults = ( } }, }, - // styles: [{ - // name: t('gauge.suggestions.style.circular', 'Glowing'), - // options: { - // effects: { - // rounded: true, - // barGlow: true, - // centerGlow: true, - // spotlight: true, - // }, - // }, - // }, { - // name: t('gauge.suggestions.style.simple', 'Simple'), - // }] } satisfies VisualizationSuggestion); const MAX_GAUGES = 10; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index a4e39d534a3..d97ce1128ba 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -7963,11 +7963,7 @@ "suggestions": { "arc": "Gauge", "circular": "Circular gauge", - "no-thresholds": "Gauge - no thresholds", - "style": { - "circular": "Glowing", - "simple": "Simple" - } + "no-thresholds": "Gauge - no thresholds" }, "threshold": "Threshold {{value}}" }, @@ -12493,16 +12489,21 @@ }, "radialbar": { "config": { + "bar-shape": "Bar Style", + "bar-shape-flat": "Flat", + "bar-shape-rounded": "Rounded", "bar-width": "Bar width", "effects": { "bar-glow": "Bar glow", "center-glow": "Center glow", "gradient": "Gradient", - "label": "Effects", - "rounded-bars": "Rounded bars", - "spotlight": "Spotlight", - "spotlight-tooltip": "Only visible in dark themes" + "label": "Effects" }, + "endpoint-marker": "Endpoint marker", + "endpoint-marker-description": "Glow is only supported in dark mode", + "endpoint-marker-glow": "Glow", + "endpoint-marker-none": "None", + "endpoint-marker-point": "Point", "segment-count": "Segments", "segment-spacing": "Segment spacing", "shape": "Style", From 6daa7ff72911e5fc3938adf500db7bafec5f5ef6 Mon Sep 17 00:00:00 2001 From: Sean Griffin Date: Fri, 19 Dec 2025 16:05:46 -0500 Subject: [PATCH 069/229] Clean up Schema Inspector feature code (#115514) Co-authored-by: Alex Spencer <52186778+alexjonspencer1@users.noreply.github.com> --- .../SchemaInspector/SchemaInspectorPanel.tsx | 80 ++-- .../SqlExpressions/SqlExpr.test.tsx | 90 +---- .../components/SqlExpressions/SqlExpr.tsx | 344 ++++++------------ .../SqlExpressions/SqlExprContext.test.tsx | 88 +++++ .../SqlExpressions/SqlExprContext.tsx | 38 ++ .../SqlExpressions/SqlQueryActions.test.tsx | 137 +++++++ .../SqlExpressions/SqlQueryActions.tsx | 91 +++++ public/locales/en-US/grafana.json | 9 +- 8 files changed, 498 insertions(+), 379 deletions(-) create mode 100644 public/app/features/expressions/components/SqlExpressions/SqlExprContext.test.tsx create mode 100644 public/app/features/expressions/components/SqlExpressions/SqlExprContext.tsx create mode 100644 public/app/features/expressions/components/SqlExpressions/SqlQueryActions.test.tsx create mode 100644 public/app/features/expressions/components/SqlExpressions/SqlQueryActions.tsx diff --git a/public/app/features/expressions/components/SqlExpressions/SchemaInspector/SchemaInspectorPanel.tsx b/public/app/features/expressions/components/SqlExpressions/SchemaInspector/SchemaInspectorPanel.tsx index c84bf0d4223..a065bbc779f 100644 --- a/public/app/features/expressions/components/SqlExpressions/SchemaInspector/SchemaInspectorPanel.tsx +++ b/public/app/features/expressions/components/SqlExpressions/SchemaInspector/SchemaInspectorPanel.tsx @@ -1,25 +1,24 @@ import { css } from '@emotion/css'; -import { useState, useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; import { - Stack, - Tab, - TabsBar, - TabContent, - Icon, + Alert, Badge, - Text, - useStyles2, + Icon, InteractiveTable, ScrollContainer, - Alert, Spinner, - IconButton, + Stack, + Tab, + TabContent, + TabsBar, + Text, + useStyles2, } from '@grafana/ui'; -import { SQLSchemas, SQLSchemaField, SQLSchemaData } from '../hooks/useSQLSchemas'; +import { SQLSchemaData, SQLSchemaField, SQLSchemas } from '../hooks/useSQLSchemas'; import { getFieldTypeIcon } from './utils'; @@ -33,10 +32,9 @@ interface SchemaInspectorPanelProps { schemas: SQLSchemas | null; loading: boolean; error: Error | null; - onClose: () => void; } -export const SchemaInspectorPanel = ({ schemas, loading, error, onClose }: SchemaInspectorPanelProps) => { +export const SchemaInspectorPanel = ({ schemas, loading, error }: SchemaInspectorPanelProps) => { const styles = useStyles2(getStyles); const schemaResponse: SQLSchemas = schemas ?? {}; @@ -192,32 +190,21 @@ export const SchemaInspectorPanel = ({ schemas, loading, error, onClose }: Schem }; return ( -
-
- {refIds.length > 0 && ( - - {refIds.map((refId) => ( - setSelectedTab(refId)} - /> - ))} - - )} - -
+ <> + {refIds.length > 0 && ( + + {refIds.map((refId) => ( + setSelectedTab(refId)} + /> + ))} + + )} {renderContent()} -
+ ); }; @@ -225,21 +212,6 @@ const getStyles = (theme: GrafanaTheme2) => ({ schemaInfoContainer: css({ padding: theme.spacing(1), }), - schemaInspector: css({ - height: '100%', - display: 'flex', - flexDirection: 'column', - }), - // Unfortunate hack to get the close button to align with the tabs since we need to - // override the default styles of the TabsBar component. - tabsBarWrapper: css({ - flexShrink: 0, - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - width: '100%', - padding: `0 ${theme.spacing(1)}`, - }), tableCell: css({ fontSize: theme.typography.bodySmall.fontSize, fontWeight: theme.typography.fontWeightMedium, @@ -247,10 +219,8 @@ const getStyles = (theme: GrafanaTheme2) => ({ }), tableContainer: css({ margin: theme.spacing(1), - flex: 1, overflowY: 'auto', overflowX: 'auto', - minHeight: 0, // Allow flex child to shrink border: `1px solid ${theme.colors.border.medium}`, borderRadius: theme.shape.radius.default, backgroundColor: theme.colors.background.primary, diff --git a/public/app/features/expressions/components/SqlExpressions/SqlExpr.test.tsx b/public/app/features/expressions/components/SqlExpressions/SqlExpr.test.tsx index b7acc586447..9e2cfd50b75 100644 --- a/public/app/features/expressions/components/SqlExpressions/SqlExpr.test.tsx +++ b/public/app/features/expressions/components/SqlExpressions/SqlExpr.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor, fireEvent, act, testWithFeatureToggles } from 'test/test-utils'; +import { act, fireEvent, render, testWithFeatureToggles } from 'test/test-utils'; import { ExpressionQuery, ExpressionQueryType } from '../../types'; @@ -127,78 +127,6 @@ describe('SqlExpr with GenAI features', () => { queries: [], }; - it('renders GenAI buttons with empty expression', async () => { - const customProps = { ...defaultProps, query: { ...defaultProps.query, expression: '' } }; - const { findByText } = render(); - expect(await findByText('Generate suggestion')).toBeInTheDocument(); - expect(await findByText('Explain query')).toBeInTheDocument(); - }); - - it('renders GenAI buttons with non-empty expression', async () => { - const { findByText } = render(); - expect(await findByText('Improve query')).toBeInTheDocument(); - expect(await findByText('Explain query')).toBeInTheDocument(); - }); - - it('renders "Improve query" when currentQuery differs from initialQuery', async () => { - const customProps = { - ...defaultProps, - query: { ...defaultProps.query, expression: 'SELECT * FROM A WHERE value > 10' }, - }; - const { findByText } = render(); - expect(await findByText('Improve query')).toBeInTheDocument(); - }); - - it('renders View explanation button when shouldShowViewExplanation is true', async () => { - const { useSQLExplanations } = require('./GenAI/hooks/useSQLExplanations'); - useSQLExplanations.mockImplementation((currentExpression: string) => ({ - shouldShowViewExplanation: true, - })); - - const { findByText } = render(); - expect(await findByText('View explanation')).toBeInTheDocument(); - }); - - it('renders Explain query button when shouldShowViewExplanation is false', async () => { - const { useSQLExplanations } = require('./GenAI/hooks/useSQLExplanations'); - useSQLExplanations.mockImplementation((currentExpression: string) => ({ - shouldShowViewExplanation: false, - })); - - const { findByText } = render(); - expect(await findByText('Explain query')).toBeInTheDocument(); - }); - - it('renders SuggestionsDrawerButton when there are suggestions', async () => { - const { useSQLSuggestions } = require('./GenAI/hooks/useSQLSuggestions'); - useSQLSuggestions.mockImplementation(() => ({ suggestions: ['suggestion1', 'suggestion2'] })); - - const { findByTestId } = render(); - expect(await findByTestId('suggestions-badge')).toBeInTheDocument(); - }); - - it('does not render SuggestionsDrawerButton when there are no suggestions', async () => { - const { useSQLSuggestions } = require('./GenAI/hooks/useSQLSuggestions'); - useSQLSuggestions.mockImplementation(() => ({ suggestions: [] })); - - const { queryByTestId } = render(); - expect(await waitFor(() => queryByTestId('suggestions-badge'))).not.toBeInTheDocument(); - }); - - it('calls handleOpenExplanation when View explanation is clicked', async () => { - const { useSQLExplanations } = require('./GenAI/hooks/useSQLExplanations'); - const mockHandleOpen = jest.fn(); - useSQLExplanations.mockImplementation(() => ({ - shouldShowViewExplanation: true, - handleOpenExplanation: mockHandleOpen, - })); - - const { findByText } = render(); - const button = await findByText('View explanation'); - fireEvent.click(button); - expect(mockHandleOpen).toHaveBeenCalled(); - }); - it('renders suggestions drawer when isDrawerOpen is true', async () => { const { useSQLSuggestions } = require('./GenAI/hooks/useSQLSuggestions'); useSQLSuggestions.mockImplementation(() => ({ @@ -245,26 +173,26 @@ describe('Schema Inspector feature toggle', () => { }); it('closes panel and shows reopen button when close button clicked', async () => { - const { queryByText, getByLabelText, findByText } = render(); + const { queryByText, getByText, findByText } = render(); expect(queryByText('No schema information available')).toBeInTheDocument(); - const closeButton = getByLabelText('Close schema inspector'); + const closeButton = getByText('Schema inspector'); await act(async () => fireEvent.click(closeButton)); expect(queryByText('No schema information available')).not.toBeInTheDocument(); - expect(await findByText('Inspect schema')).toBeInTheDocument(); + expect(await findByText('Schema inspector')).toBeInTheDocument(); }); - it('reopens panel when inspect schema button clicked after closing', async () => { - const { queryByText, getByLabelText, getByText } = render(); + it('reopens panel when Open schema inspector button clicked after closing', async () => { + const { queryByText, getByText } = render(); - const closeButton = getByLabelText('Close schema inspector'); + const closeButton = getByText('Schema inspector'); await act(async () => fireEvent.click(closeButton)); expect(queryByText('No schema information available')).not.toBeInTheDocument(); - const reopenButton = getByText('Inspect schema'); + const reopenButton = getByText('Schema inspector'); await act(async () => fireEvent.click(reopenButton)); expect(queryByText('No schema information available')).toBeInTheDocument(); @@ -300,7 +228,7 @@ describe('Schema Inspector feature toggle', () => { it('does not render panel or button', () => { const { queryByText } = render(); - expect(queryByText('Inspect schema')).not.toBeInTheDocument(); + expect(queryByText('Schema inspector')).not.toBeInTheDocument(); expect(queryByText('No schema information available')).not.toBeInTheDocument(); }); }); diff --git a/public/app/features/expressions/components/SqlExpressions/SqlExpr.tsx b/public/app/features/expressions/components/SqlExpressions/SqlExpr.tsx index 361d748b716..a59b46b232b 100644 --- a/public/app/features/expressions/components/SqlExpressions/SqlExpr.tsx +++ b/public/app/features/expressions/components/SqlExpressions/SqlExpr.tsx @@ -1,15 +1,15 @@ import { css, cx } from '@emotion/css'; -import { useMemo, useRef, useEffect, useState, lazy, Suspense, useCallback } from 'react'; +import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react'; import { useMeasure } from 'react-use'; import AutoSizer from 'react-virtualized-auto-sizer'; -import { SelectableValue, GrafanaTheme2 } from '@grafana/data'; -import { t, Trans } from '@grafana/i18n'; -import { SQLEditor, CompletionItemKind, LanguageDefinition, TableIdentifier } from '@grafana/plugin-ui'; +import { GrafanaTheme2, SelectableValue } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; +import { CompletionItemKind, LanguageDefinition, SQLEditor, TableIdentifier } from '@grafana/plugin-ui'; import { reportInteraction } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema/dist/esm/index'; import { formatSQL } from '@grafana/sql'; -import { useStyles2, Stack, Button, Modal } from '@grafana/ui'; +import { Button, Stack, useStyles2 } from '@grafana/ui'; import { ExpressionQueryEditorProps } from '../../ExpressionQueryEditor'; import { SqlExpressionQuery } from '../../types'; @@ -20,27 +20,10 @@ import { getSqlCompletionProvider } from './CompletionProvider/sqlCompletionProv import { useSQLExplanations } from './GenAI/hooks/useSQLExplanations'; import { useSQLSuggestions } from './GenAI/hooks/useSQLSuggestions'; import { SchemaInspectorPanel } from './SchemaInspector/SchemaInspectorPanel'; +import { SqlExprContextValue, SqlExprProvider } from './SqlExprContext'; +import { SqlQueryActions } from './SqlQueryActions'; import { useSQLSchemas } from './hooks/useSQLSchemas'; -// Lazy load the GenAI components to avoid circular dependencies -const GenAISQLSuggestionsButton = lazy(() => - import('./GenAI/GenAISQLSuggestionsButton').then((module) => ({ - default: module.GenAISQLSuggestionsButton, - })) -); - -const GenAISQLExplainButton = lazy(() => - import('./GenAI/GenAISQLExplainButton').then((module) => ({ - default: module.GenAISQLExplainButton, - })) -); - -const SuggestionsDrawerButton = lazy(() => - import('./GenAI/SuggestionsDrawerButton').then((module) => ({ - default: module.SuggestionsDrawerButton, - })) -); - const GenAISuggestionsDrawer = lazy(() => import('./GenAI/GenAISuggestionsDrawer').then((module) => ({ default: module.GenAISuggestionsDrawer, @@ -55,7 +38,6 @@ const GenAIExplanationDrawer = lazy(() => // Account for Monaco editor's border to prevent clipping const EDITOR_BORDER_ADJUSTMENT = 2; // 1px border on top and bottom -const EDITOR_HEIGHT = 300; export interface SqlExprProps { refIds: Array>; @@ -93,14 +75,10 @@ FROM LIMIT 10`; - const [dimensions, setDimensions] = useState({ height: 0 }); - const styles = useStyles2((theme) => getStyles(theme, dimensions.height || EDITOR_HEIGHT)); - const containerRef = useRef(null); const [toolboxRef, toolboxMeasure] = useMeasure(); - const [isExpanded, setIsExpanded] = useState(false); const [isSchemaInspectorOpen, setIsSchemaInspectorOpen] = useState(true); - - const { handleApplySuggestion, handleHistoryUpdate, handleCloseDrawer, handleOpenDrawer, isDrawerOpen, suggestions } = + const styles = useStyles2((theme) => getStyles(theme)); + const { handleApplySuggestion, handleCloseDrawer, handleHistoryUpdate, handleOpenDrawer, isDrawerOpen, suggestions } = useSQLSuggestions(); const { @@ -195,21 +173,6 @@ LIMIT } }, [onRunQuery, refetchSchemas, isSchemaInspectorOpen]); - // Set up resize observer to handle container resizing - useEffect(() => { - if (!containerRef.current) { - return; - } - - const resizeObserver = new ResizeObserver((entries) => { - const { height } = entries[0].contentRect; - setDimensions({ height }); - }); - - resizeObserver.observe(containerRef.current); - return () => resizeObserver.disconnect(); - }, []); - useEffect(() => { // Call the onChange method once so we have access to the initial query in consuming components // But only if expression is empty @@ -236,168 +199,122 @@ LIMIT return () => document.removeEventListener('keydown', handleKeyDown, true); }, [executeQuery]); - const renderToolbox = (formatQuery: () => void) => ( -
- -
- ); + const contextValue: SqlExprContextValue = { + // Explanations + explanation, + isExplanationOpen, + shouldShowViewExplanation, + handleExplain, + handleOpenExplanation, + handleCloseExplanation, + // Suggestions + suggestions, + isDrawerOpen, + handleHistoryUpdate, + handleApplySuggestion, + handleOpenDrawer, + handleCloseDrawer, + }; - const renderSQLButtons = () => ( -
- - {isSchemasFeatureEnabled && !isSchemaInspectorOpen && ( - - )} - - - {shouldShowViewExplanation ? ( - - ) : ( - - )} - - - {}} // Noop - history is managed via onHistoryUpdate - onHistoryUpdate={handleHistoryUpdate} - queryContext={queryContext} - refIds={vars} - errorContext={errorContext} // Will be added when error tracking is implemented - // schemas={schemas} // Will be added when schema extraction is implemented - /> - - - {suggestions.length > 0 && ( - - - - )} -
- ); - - const renderSQLEditor = (width?: number, height?: number) => ( - <> -
- {renderSQLButtons()} -
( + + + {isSchemasFeatureEnabled && ( + + )} + + ); + + const renderMainContent = () => ( +
+
+ + {({ width, height }) => ( - {({ formatQuery }) => renderToolbox(formatQuery)} + {({ formatQuery }) => ( +
+ +
+ )}
-
- {isSchemaInspectorOpen && isSchemasFeatureEnabled && ( -
- setIsSchemaInspectorOpen(false)} - /> -
)} -
+
- - - - - - - + {isSchemaInspectorOpen && isSchemasFeatureEnabled && ( +
+ +
+ )} +
); - const renderStandaloneEditor = () => ( - - {({ width, height }) => ( - - {({ formatQuery }) => renderToolbox(formatQuery)} - - )} - + const renderSQLEditor = () => ( + + {renderButtons()} + {renderMainContent()} + ); return ( - <> - {renderSQLEditor()} - {isExpanded && ( - setIsExpanded(false)} - > - {renderStandaloneEditor()} - - )} - + +
+ {renderSQLEditor()} + + + + + + +
+
); }; -const getStyles = (theme: GrafanaTheme2, editorHeight: number) => ({ - sqlContainer: css({ - display: 'grid', - gap: theme.spacing(1), - gridTemplateRows: 'auto 1fr', - gridTemplateAreas: ` - "buttons" - "content" - `, +const getStyles = (theme: GrafanaTheme2) => ({ + mainContainer: css({ + marginTop: theme.spacing(0.5), }), - contentContainer: css({ - gridArea: 'content', + minHeight: '250px', + height: '100%', + resize: 'vertical', + overflow: 'hidden', + display: 'grid', - gap: theme.spacing(1), gridTemplateColumns: '1fr 0fr', gridTemplateAreas: '"editor schema"', [theme.transitions.handleMotion('no-preference')]: { @@ -408,67 +325,22 @@ const getStyles = (theme: GrafanaTheme2, editorHeight: number) => ({ }), contentContainerWithSchema: css({ gridTemplateColumns: '1fr 1fr', + gap: theme.spacing(1), }), editorContainer: css({ gridArea: 'editor', - height: editorHeight, // Use dynamic height from ResizeObserver - resize: 'vertical', - overflow: 'auto', - minHeight: '100px', - }), - modal: css({ - width: '95vw', - height: '95vh', - }), - modalContent: css({ height: '100%', - paddingTop: 0, - }), - // This is NOT ideal. The alternative is to expose SQL buttons as a separate component, - // Then consume them in ExpressionQueryEditor. This requires a lot of refactoring and - // can be prioritized later. - sqlButtons: css({ - gridArea: 'buttons', - justifySelf: 'end', - transform: `translateY(${theme.spacing(-4)})`, - marginBottom: theme.spacing(-4), // Prevent affecting editor position - zIndex: 10, // Ensure buttons appear above other elements - position: 'relative', // Required for z-index to work - display: 'flex', - alignItems: 'center', - gap: theme.spacing(1), + width: '100%', + overflow: 'auto', }), schemaInspector: css({ gridArea: 'schema', - height: editorHeight, + height: '100%', overflow: 'hidden', minWidth: 0, - }), - schemaInspectorOpen: css({ border: `1px solid ${theme.colors.border.weak}`, borderRadius: theme.shape.radius.default, }), - schemaFields: css({ - display: 'flex', - flexWrap: 'wrap', - gap: theme.spacing(1), - padding: theme.spacing(1), - maxHeight: '120px', - overflowY: 'auto', - }), - fieldItem: css({ - display: 'flex', - alignItems: 'center', - gap: theme.spacing(0.5), - padding: theme.spacing(1), - backgroundColor: theme.colors.background.secondary, - borderRadius: theme.shape.radius.default, - border: `1px solid ${theme.colors.border.weak}`, - fontSize: theme.typography.bodySmall.fontSize, - }), - responseContainer: css({ - padding: theme.spacing(2), - }), }); async function fetchFields(identifier: TableIdentifier, queries: DataQuery[]) { diff --git a/public/app/features/expressions/components/SqlExpressions/SqlExprContext.test.tsx b/public/app/features/expressions/components/SqlExpressions/SqlExprContext.test.tsx new file mode 100644 index 00000000000..f95128b5af6 --- /dev/null +++ b/public/app/features/expressions/components/SqlExpressions/SqlExprContext.test.tsx @@ -0,0 +1,88 @@ +import { render, screen } from 'test/test-utils'; + +import { SqlExprContextValue, SqlExprProvider, useSqlExprContext } from './SqlExprContext'; + +describe('SqlExprContext', () => { + const mockContextValue: SqlExprContextValue = { + explanation: 'Test explanation', + isExplanationOpen: false, + shouldShowViewExplanation: false, + handleExplain: jest.fn(), + handleOpenExplanation: jest.fn(), + handleCloseExplanation: jest.fn(), + suggestions: ['suggestion1', 'suggestion2'], + isDrawerOpen: false, + handleHistoryUpdate: jest.fn(), + handleApplySuggestion: jest.fn(), + handleOpenDrawer: jest.fn(), + handleCloseDrawer: jest.fn(), + }; + + describe('SqlExprProvider', () => { + it('renders children correctly', () => { + render( + +
Test Child
+
+ ); + + expect(screen.getByText('Test Child')).toBeInTheDocument(); + }); + + it('provides context value to children', () => { + const TestConsumer = () => { + const context = useSqlExprContext(); + return
{context.explanation}
; + }; + + render( + + + + ); + + expect(screen.getByText('Test explanation')).toBeInTheDocument(); + }); + }); + + describe('useSqlExprContext', () => { + it('throws error when used outside provider', () => { + const TestComponent = () => { + useSqlExprContext(); + return
Should not render
; + }; + + // Suppress console.error for this test + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + render(); + }).toThrow('useSqlExprContext must be used within SqlExprProvider'); + + consoleSpy.mockRestore(); + }); + + it('returns context value when used inside provider', () => { + const TestComponent = () => { + const context = useSqlExprContext(); + return ( +
+ Explanation: {context.explanation} + Suggestions: {context.suggestions.length} + Is Drawer Open: {context.isDrawerOpen.toString()} +
+ ); + }; + + render( + + + + ); + + expect(screen.getByText('Explanation: Test explanation')).toBeInTheDocument(); + expect(screen.getByText('Suggestions: 2')).toBeInTheDocument(); + expect(screen.getByText('Is Drawer Open: false')).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/expressions/components/SqlExpressions/SqlExprContext.tsx b/public/app/features/expressions/components/SqlExpressions/SqlExprContext.tsx new file mode 100644 index 00000000000..e05ccff80b6 --- /dev/null +++ b/public/app/features/expressions/components/SqlExpressions/SqlExprContext.tsx @@ -0,0 +1,38 @@ +import { createContext, useContext, ReactNode } from 'react'; + +export interface SqlExprContextValue { + // Explanations + explanation: string; + isExplanationOpen: boolean; + shouldShowViewExplanation: boolean; + handleExplain: (explanation: string) => void; + handleOpenExplanation: () => void; + handleCloseExplanation: () => void; + + // Suggestions + suggestions: string[]; + isDrawerOpen: boolean; + handleHistoryUpdate: (suggestions: string[]) => void; + handleApplySuggestion: (suggestion: string) => string; + handleOpenDrawer: () => void; + handleCloseDrawer: () => void; +} + +const SqlExprContext = createContext(null); + +export const useSqlExprContext = () => { + const context = useContext(SqlExprContext); + if (!context) { + throw new Error('useSqlExprContext must be used within SqlExprProvider'); + } + return context; +}; + +interface SqlExprProviderProps { + children: ReactNode; + value: SqlExprContextValue; +} + +export const SqlExprProvider = ({ children, value }: SqlExprProviderProps) => { + return {children}; +}; diff --git a/public/app/features/expressions/components/SqlExpressions/SqlQueryActions.test.tsx b/public/app/features/expressions/components/SqlExpressions/SqlQueryActions.test.tsx new file mode 100644 index 00000000000..93e3285ed83 --- /dev/null +++ b/public/app/features/expressions/components/SqlExpressions/SqlQueryActions.test.tsx @@ -0,0 +1,137 @@ +import { fireEvent, render, waitFor } from 'test/test-utils'; + +import { SqlExprContextValue } from './SqlExprContext'; +import { SqlQueryActions, SqlQueryActionsProps } from './SqlQueryActions'; + +jest.mock('@grafana/ui', () => ({ + ...jest.requireActual('@grafana/ui'), + useStyles2: jest.fn().mockImplementation(() => ({})), +})); + +// Mock lazy loaded GenAI components +jest.mock('./GenAI/GenAISQLSuggestionsButton', () => ({ + GenAISQLSuggestionsButton: ({ currentQuery, initialQuery }: { currentQuery: string; initialQuery: string }) => { + const text = !currentQuery || currentQuery === initialQuery ? 'Generate suggestion' : 'Improve query'; + return
{text}
; + }, +})); + +jest.mock('./GenAI/GenAISQLExplainButton', () => ({ + GenAISQLExplainButton: () =>
Explain query
, +})); + +jest.mock('./GenAI/SuggestionsDrawerButton', () => ({ + SuggestionsDrawerButton: () =>
Suggestions Badge
, +})); + +// Mock SqlExprContext +const mockContextValue: SqlExprContextValue = { + handleOpenExplanation: jest.fn(), + shouldShowViewExplanation: false, + handleExplain: jest.fn(), + handleHistoryUpdate: jest.fn(), + handleOpenDrawer: jest.fn(), + suggestions: [], + explanation: '', + isExplanationOpen: false, + isDrawerOpen: false, + handleApplySuggestion: jest.fn(), + handleCloseDrawer: jest.fn(), + handleCloseExplanation: jest.fn(), +}; + +jest.mock('./SqlExprContext', () => ({ + useSqlExprContext: () => mockContextValue, + SqlExprProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +describe('SqlQueryActions', () => { + const defaultProps: SqlQueryActionsProps = { + executeQuery: jest.fn(), + currentQuery: `SELECT * FROM A LIMIT 10`, + queryContext: {}, + refIds: ['A'], + initialQuery: `SELECT * FROM A LIMIT 10`, + errorContext: [], + }; + + beforeEach(() => { + jest.clearAllMocks(); + // Reset mock context to default values + Object.assign(mockContextValue, { + handleOpenExplanation: jest.fn(), + shouldShowViewExplanation: false, + handleExplain: jest.fn(), + handleHistoryUpdate: jest.fn(), + handleOpenDrawer: jest.fn(), + suggestions: [], + explanation: '', + isExplanationOpen: false, + isDrawerOpen: false, + handleApplySuggestion: jest.fn(), + handleCloseDrawer: jest.fn(), + handleCloseExplanation: jest.fn(), + }); + }); + + it('renders GenAI buttons with empty expression', async () => { + const customProps = { ...defaultProps, currentQuery: '' }; + const { findByText } = render(); + expect(await findByText('Generate suggestion')).toBeInTheDocument(); + expect(await findByText('Explain query')).toBeInTheDocument(); + }); + + it('renders GenAI buttons with non-empty expression', async () => { + const { findByText } = render(); + expect(await findByText('Generate suggestion')).toBeInTheDocument(); + expect(await findByText('Explain query')).toBeInTheDocument(); + }); + + it('renders "Improve query" when currentQuery differs from initialQuery', async () => { + const customProps = { + ...defaultProps, + currentQuery: 'SELECT * FROM A WHERE value > 10', + }; + const { findByText } = render(); + expect(await findByText('Improve query')).toBeInTheDocument(); + }); + + it('renders View explanation button when shouldShowViewExplanation is true', async () => { + mockContextValue.shouldShowViewExplanation = true; + + const { findByText } = render(); + expect(await findByText('View explanation')).toBeInTheDocument(); + }); + + it('renders Explain query button when shouldShowViewExplanation is false', async () => { + mockContextValue.shouldShowViewExplanation = false; + + const { findByText } = render(); + expect(await findByText('Explain query')).toBeInTheDocument(); + }); + + it('renders SuggestionsDrawerButton when there are suggestions', async () => { + mockContextValue.suggestions = ['suggestion1', 'suggestion2']; + + const { findByTestId } = render(); + expect(await findByTestId('suggestions-badge')).toBeInTheDocument(); + }); + + it('does not render SuggestionsDrawerButton when there are no suggestions', async () => { + mockContextValue.suggestions = []; + + const { queryByTestId } = render(); + expect(await waitFor(() => queryByTestId('suggestions-badge'))).not.toBeInTheDocument(); + }); + + it('calls handleOpenExplanation when View explanation is clicked', async () => { + const mockHandleOpen = jest.fn(); + mockContextValue.shouldShowViewExplanation = true; + mockContextValue.handleOpenExplanation = mockHandleOpen; + + const { findByText } = render(); + const button = await findByText('View explanation'); + fireEvent.click(button); + expect(mockHandleOpen).toHaveBeenCalled(); + }); +}); diff --git a/public/app/features/expressions/components/SqlExpressions/SqlQueryActions.tsx b/public/app/features/expressions/components/SqlExpressions/SqlQueryActions.tsx new file mode 100644 index 00000000000..a96a82a4e72 --- /dev/null +++ b/public/app/features/expressions/components/SqlExpressions/SqlQueryActions.tsx @@ -0,0 +1,91 @@ +import { lazy, Suspense } from 'react'; + +import { t, Trans } from '@grafana/i18n'; +import { Button, Stack } from '@grafana/ui'; + +import { useSqlExprContext } from './SqlExprContext'; + +// Lazy load the GenAI components to avoid circular dependencies +const GenAISQLSuggestionsButton = lazy(() => + import('./GenAI/GenAISQLSuggestionsButton').then((module) => ({ + default: module.GenAISQLSuggestionsButton, + })) +); + +const GenAISQLExplainButton = lazy(() => + import('./GenAI/GenAISQLExplainButton').then((module) => ({ + default: module.GenAISQLExplainButton, + })) +); + +const SuggestionsDrawerButton = lazy(() => + import('./GenAI/SuggestionsDrawerButton').then((module) => ({ + default: module.SuggestionsDrawerButton, + })) +); + +export interface SqlQueryActionsProps { + executeQuery: () => void; + currentQuery: string; + queryContext: Record; + refIds: string[]; + initialQuery: string; + errorContext: string[]; +} + +export const SqlQueryActions = ({ + executeQuery, + currentQuery, + queryContext, + refIds, + initialQuery, + errorContext, +}: SqlQueryActionsProps) => { + const { + handleOpenExplanation, + shouldShowViewExplanation, + handleExplain, + handleHistoryUpdate, + handleOpenDrawer, + suggestions, + } = useSqlExprContext(); + return ( + + + + {shouldShowViewExplanation ? ( + + ) : ( + + )} + + + {}} // Noop - history is managed via onHistoryUpdate + onHistoryUpdate={handleHistoryUpdate} + queryContext={queryContext} + refIds={refIds} + errorContext={errorContext} // Will be added when error tracking is implemented + // schemas={schemas} // Will be added when schema extraction is implemented + /> + + {suggestions.length > 0 && ( + + + + )} + + ); +}; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index d97ce1128ba..807d6fbfa4b 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -7872,23 +7872,18 @@ "label-upsample": "Upsample", "tooltip-s-m-h": "10s, 1m, 30m, 1h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "Close schema inspector" - }, "sql-expr": { "button-run-query": "Run query", - "modal-title": "SQL Editor", "tooltip-experimental": "SQL Expressions LLM integration is experimental. Please report any issues to the Grafana team." }, "sql-schema": { - "close-schema-inspector": "Close schema inspector", "error-title": "Error", - "inspect-button": "Inspect schema", "loading": "Loading schema information...", "no-data-title": "No schema information available", "no-fields-desc": "This query returned no schema information.", "no-fields-title": "No schema information", - "query-error-title": "Query error" + "query-error-title": "Query error", + "schema-inspector": "Schema inspector" }, "threshold": { "label-input": "Input" From 5585595c16f633a2a1e6ac8efefc6f7ff0e22013 Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Sat, 20 Dec 2025 00:41:06 +0000 Subject: [PATCH 070/229] I18n: Download translations from Crowdin (#115604) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- public/locales/cs-CZ/grafana.json | 135 +++++++++++++++++----------- public/locales/de-DE/grafana.json | 135 +++++++++++++++++----------- public/locales/es-ES/grafana.json | 135 +++++++++++++++++----------- public/locales/fr-FR/grafana.json | 135 +++++++++++++++++----------- public/locales/hu-HU/grafana.json | 135 +++++++++++++++++----------- public/locales/id-ID/grafana.json | 135 +++++++++++++++++----------- public/locales/it-IT/grafana.json | 135 +++++++++++++++++----------- public/locales/ja-JP/grafana.json | 135 +++++++++++++++++----------- public/locales/ko-KR/grafana.json | 135 +++++++++++++++++----------- public/locales/nl-NL/grafana.json | 135 +++++++++++++++++----------- public/locales/pl-PL/grafana.json | 135 +++++++++++++++++----------- public/locales/pt-BR/grafana.json | 135 +++++++++++++++++----------- public/locales/pt-PT/grafana.json | 135 +++++++++++++++++----------- public/locales/ru-RU/grafana.json | 135 +++++++++++++++++----------- public/locales/sv-SE/grafana.json | 135 +++++++++++++++++----------- public/locales/tr-TR/grafana.json | 135 +++++++++++++++++----------- public/locales/zh-Hans/grafana.json | 135 +++++++++++++++++----------- public/locales/zh-Hant/grafana.json | 135 +++++++++++++++++----------- 18 files changed, 1476 insertions(+), 954 deletions(-) diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index 575bba19ca1..84cc597980b 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -2688,6 +2688,35 @@ "text-federated": "Federované", "text-provisioned": "Zajištěno" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Zdroj dat", @@ -3749,6 +3778,7 @@ "text": "Nebyly nalezeny žádné výsledky pro váš dotaz" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6222,7 +6252,7 @@ "title-someone-else-has-updated-this-dashboard": "Tuto nástěnku aktualizoval jiný uživatel", "would-still-dashboard": "Chcete přesto tuto nástěnku uložit?" }, - "save-and-overwrite": "Uložit a přepsat" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7211,6 +7241,9 @@ "time-range-label": "Zamknout časový rozsah" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Pro tip: {{proTip}}" }, @@ -7691,39 +7724,6 @@ }, "share-span": "Sdílet" }, - "span-filters": { - "aria-label-select-max-span-operator": "Vyberte operátor max. rozsahu", - "aria-label-select-min-span-operator": "Vyberte operátor min. rozsahu", - "aria-label-select-service-name": "Vyberte název služby", - "aria-label-select-service-name-operator": "Vyberte operátor názvu služby", - "aria-label-select-span-name": "Vyberte název rozsahu", - "aria-label-select-span-name-operator": "Vyberte operátor názvu rozsahu", - "ariaLabel-select-max-span-duration": "Vyberte maximální dobu trvání", - "ariaLabel-select-min-span-duration": "Vyberte minimální dobu trvání rozsahu", - "label-collapse": "Filtry rozsahu", - "label-duration": "Doba trvání", - "label-service-name": "Název služby", - "label-span-name": "Název rozsahu", - "label-tags": "Tagy", - "placeholder-all-service-names": "Všechny názvy služby", - "placeholder-all-span-names": "Všechny názvy rozsahu", - "tooltip-collapse": "Filtrujte svá rozpětí níže. Filtry můžete používat tak dlouho, dokud nezúžíte výsledná rozpětí na několik vybraných, které vás nejvíce zajímají.", - "tooltip-duration": "Filtrovat podle doby trvání. Akceptované jednotky jsou {{units}}", - "tooltip-tags": "Filtrujte podle tagů, tagů procesů nebo polí protokolu ve vybraném rozpětí." - }, - "span-filters-tags": { - "aria-label-add-tag": "Přidat tag", - "aria-label-input-tag-value": "Vstupní hodnota tagu", - "aria-label-remove-tag": "Odebrat tag", - "aria-label-select-tag-key": "Vyberte klíč tagu", - "aria-label-select-tag-operator": "Vyberte operátor tagu", - "aria-label-select-tag-value": "Vyberte hodnotu tagu", - "placeholder-select-tag": "Vyberte tag", - "placeholder-select-value": "Vyberte hodnotu", - "placeholder-tag-value": "Hodnota tagu", - "tooltip-add-tag": "Přidat tag", - "tooltip-remove-tag": "Odebrat tag" - }, "span-flame-graph": { "flame-graph": "Graf plamene" }, @@ -7922,23 +7922,18 @@ "label-upsample": "Zvýšit vzorkovací frekvenci", "tooltip-s-m-h": "10s, 1m, 30m, 1h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Spustit dotaz", - "modal-title": "Editor SQL", "tooltip-experimental": "Integrace LLM pro výrazy jazyka SQL je experimentální. Jakékoli problémy nahlaste týmu Grafana." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Vstup" @@ -8013,11 +8008,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9463,6 +9454,7 @@ "name-unit": "Jednotka", "name-value-name": "Název hodnoty", "name-y-axis-scale": "Měřítko osy Y", + "name-y-bucket-scale": "", "placeholder-axis-label": "Auto", "placeholder-axis-width": "Auto", "placeholder-decimals": "Auto", @@ -9492,6 +9484,18 @@ "label-all": "Vše", "label-hidden": "Skryté", "label-single": "Jednorázový" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11295,9 +11299,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -12074,7 +12083,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Zobrazit podrobnosti", "loading-finished-job": "Načítání dokončené úlohy…", @@ -12180,6 +12192,9 @@ "webhook-last-event": "Poslední událost:", "webhook-url": "Zobrazit webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Zpět na úložiště", "cleaning-up-resources": "Čištění zdrojů úložiště", @@ -12284,6 +12299,9 @@ "tooltip-unhealthy-repository": "Nelze stáhnout nezdravé úložiště" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12303,10 +12321,15 @@ }, "warning-title-default": "Varování", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "Doba trvání tohoto procesu závisí na počtu zapojených zdrojů.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12325,6 +12348,7 @@ "step-finish": "Vybrat další nastavení", "step-synchronize": "Synchronizovat s externím úložištěm", "sync-description": "Synchronizujte zdroje s externím úložištěm. Po tomto jednorázovém kroku budou všechny budoucí aktualizace automaticky uloženy do úložiště a zajištěny zpět do instance.", + "sync-option-migrate-resources": "", "title-bootstrap": "Vyberte, co chcete synchronizovat", "title-connect": "Připojit k externímu úložišti", "title-finish": "Vybrat další nastavení", @@ -12564,16 +12588,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 9df0dd8a2da..ef5f649123d 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -2666,6 +2666,35 @@ "text-federated": "Verbunden", "text-provisioned": "Bereitgestellt" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Datenquelle", @@ -3717,6 +3746,7 @@ "text": "Keine Ergebnisse für deine Abfrage gefunden" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6180,7 +6210,7 @@ "title-someone-else-has-updated-this-dashboard": "Eine andere Person hat dieses Dashboard aktualisiert", "would-still-dashboard": "Möchten Sie dieses Dashboard trotzdem speichern?" }, - "save-and-overwrite": "'Speichern und überschreiben'" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7195,9 @@ "time-range-label": "Zeitbereich sperren" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Profitipp: {{proTip}}" }, @@ -7641,39 +7674,6 @@ }, "share-span": "Teilen" }, - "span-filters": { - "aria-label-select-max-span-operator": "Höchstspannen-Operator auswählen", - "aria-label-select-min-span-operator": "Mindestspannen-Operator auswählen", - "aria-label-select-service-name": "Dienstname auswählen", - "aria-label-select-service-name-operator": "Dienstnamen-Operator auswählen", - "aria-label-select-span-name": "Spannen-Name auswählen", - "aria-label-select-span-name-operator": "Spannen-Namen-Operator auswählen", - "ariaLabel-select-max-span-duration": "Dauer der max. Spanne auswählen", - "ariaLabel-select-min-span-duration": "Dauer der min. Spanne auswählen", - "label-collapse": "Spannenfilter", - "label-duration": "Dauer", - "label-service-name": "Dienstname", - "label-span-name": "Spannen-Name", - "label-tags": "Tags", - "placeholder-all-service-names": "Alle Dienstnamen", - "placeholder-all-span-names": "Alle Spannen-Namen", - "tooltip-collapse": "Filtern Sie unten Ihre Spannen. Sie können weiterhin Filter anwenden, bis Sie Ihre resultierenden Spannen auf die wenigen eingegrenzt haben, die für Sie am meisten von Interesse sind.", - "tooltip-duration": "Nach Dauer filtern. Zulässige Einheiten sind {{units}}", - "tooltip-tags": "Filtern Sie in Ihren Spannen nach Tags, Prozess-Tags oder Log-Feldern." - }, - "span-filters-tags": { - "aria-label-add-tag": "Tag hinzufügen", - "aria-label-input-tag-value": "Tag-Wert eingeben", - "aria-label-remove-tag": "Tag entfernen", - "aria-label-select-tag-key": "Tag-Key auswählen", - "aria-label-select-tag-operator": "Tag-Operator auswählen", - "aria-label-select-tag-value": "Tag-Wert auswählen", - "placeholder-select-tag": "Tag auswählen", - "placeholder-select-value": "Wert auswählen", - "placeholder-tag-value": "Tag-Wert", - "tooltip-add-tag": "Tag hinzufügen", - "tooltip-remove-tag": "Tag entfernen" - }, "span-flame-graph": { "flame-graph": "Flammendiagramm" }, @@ -7872,23 +7872,18 @@ "label-upsample": "Upsample", "tooltip-s-m-h": "10 s, 1 min., 30 min., 1 h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Abfrage ausführen", - "modal-title": "SQL Editor", "tooltip-experimental": "Die Integration des SQL Expressions LLM ist experimentell. Bitte melden Sie Probleme dem Grafana-Team." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Eingabe" @@ -7963,11 +7958,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9384,7 @@ "name-unit": "Einheit", "name-value-name": "Wertname", "name-y-axis-scale": "Y-Achsenskala", + "name-y-bucket-scale": "", "placeholder-axis-label": "Auto", "placeholder-axis-width": "Auto", "placeholder-decimals": "Auto", @@ -9422,6 +9414,18 @@ "label-all": "Alles", "label-hidden": "Ausgeblendet", "label-single": "Einzeln" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11207,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11981,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Details anzeigen", "loading-finished-job": "Fertiger Auftrag wird geladen …", @@ -12078,6 +12090,9 @@ "webhook-last-event": "Letztes Ereignis:", "webhook-url": "Webhook anzeigen" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Zurück zu den Repositorys", "cleaning-up-resources": "Bereinigen von Repository-Ressourcen", @@ -12182,6 +12197,9 @@ "tooltip-unhealthy-repository": "Ein fehlerhaftes Repository kann nicht abgerufen werden" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12219,15 @@ }, "warning-title-default": "Warnung", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "Die Dauer dieses Prozesses hängt von der Anzahl der betroffenen Ressourcen ab.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12246,7 @@ "step-finish": "Zusätzliche Einstellungen auswählen", "step-synchronize": "Mit externem Speicher synchronisieren", "sync-description": "Synchronisieren Sie Ressourcen mit externem Speicher. Nach diesem einmaligen Schritt werden alle zukünftigen Updates automatisch im Repository gespeichert und wieder in der Instanz bereitgestellt.", + "sync-option-migrate-resources": "", "title-bootstrap": "Wählen Sie aus, was synchronisiert wird", "title-connect": "Mit externem Speicher verbinden", "title-finish": "Zusätzliche Einstellungen auswählen", @@ -12460,16 +12484,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 1c8c641cfad..443dbefbc39 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -2666,6 +2666,35 @@ "text-federated": "Federado", "text-provisioned": "Provisionado" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Fuente de datos", @@ -3717,6 +3746,7 @@ "text": "No se han encontrado resultados para tu consulta" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6180,7 +6210,7 @@ "title-someone-else-has-updated-this-dashboard": "Otra persona ha actualizado este dashboard", "would-still-dashboard": "¿Seguro que quieres guardar este dashboard?" }, - "save-and-overwrite": "«Guardar y sobrescribir»" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7195,9 @@ "time-range-label": "Bloquear el intervalo de tiempo" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Consejo profesional: {{proTip}}" }, @@ -7641,39 +7674,6 @@ }, "share-span": "Compartir" }, - "span-filters": { - "aria-label-select-max-span-operator": "Seleccionar operador de intervalo máximo", - "aria-label-select-min-span-operator": "Seleccionar operador de intervalo mínimo", - "aria-label-select-service-name": "Seleccionar nombre de servicio", - "aria-label-select-service-name-operator": "Seleccionar operador de nombre de servicio", - "aria-label-select-span-name": "Seleccionar nombre de intervalo", - "aria-label-select-span-name-operator": "Seleccionar operador de nombre de intervalo", - "ariaLabel-select-max-span-duration": "Seleccionar duración máxima del intervalo", - "ariaLabel-select-min-span-duration": "Seleccionar duración mínima del intervalo", - "label-collapse": "Filtros de intervalo", - "label-duration": "Duración", - "label-service-name": "Nombre de servicio", - "label-span-name": "Nombre del intervalo", - "label-tags": "Etiquetas", - "placeholder-all-service-names": "Todos los nombres de servicios", - "placeholder-all-span-names": "Todos los nombres de intervalo", - "tooltip-collapse": "Filtre sus intervalos a continuación. Puede seguir aplicando filtros hasta que haya reducido los intervalos resultantes a los que más le interesen.", - "tooltip-duration": "Filtra por duración. Las unidades aceptadas son {{units}}", - "tooltip-tags": "Filtra por etiquetas, etiquetas de proceso o campos de logs en tus intervalos." - }, - "span-filters-tags": { - "aria-label-add-tag": "Añadir etiqueta", - "aria-label-input-tag-value": "Introducir valor de la etiqueta", - "aria-label-remove-tag": "Quitar etiqueta", - "aria-label-select-tag-key": "Seleccionar clave de etiqueta", - "aria-label-select-tag-operator": "Seleccionar operador de etiqueta", - "aria-label-select-tag-value": "Seleccionar valor de etiqueta", - "placeholder-select-tag": "Seleccionar etiqueta", - "placeholder-select-value": "Seleccionar valor", - "placeholder-tag-value": "Valor de etiqueta", - "tooltip-add-tag": "Añadir etiqueta", - "tooltip-remove-tag": "Quitar etiqueta" - }, "span-flame-graph": { "flame-graph": "Gráfico de llama" }, @@ -7872,23 +7872,18 @@ "label-upsample": "Aumentar tamaño", "tooltip-s-m-h": "10 s, 1 m, 30 m, 1 h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Ejecutar consulta", - "modal-title": "Editor de SQL", "tooltip-experimental": "La integración de LLM de expresiones SQL es experimental. Avisa de cualquier problema al equipo de Grafana." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Entrada" @@ -7963,11 +7958,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9384,7 @@ "name-unit": "Unidad", "name-value-name": "Nombre del valor", "name-y-axis-scale": "Escala del eje Y", + "name-y-bucket-scale": "", "placeholder-axis-label": "Auto", "placeholder-axis-width": "Auto", "placeholder-decimals": "Auto", @@ -9422,6 +9414,18 @@ "label-all": "Todo", "label-hidden": "Oculto", "label-single": "Único" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11207,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11981,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Ver detalles", "loading-finished-job": "Cargando trabajo finalizado...", @@ -12078,6 +12090,9 @@ "webhook-last-event": "Último evento:", "webhook-url": "Ver webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Volver a los repositorios", "cleaning-up-resources": "Limpiando los recursos del repositorio", @@ -12182,6 +12197,9 @@ "tooltip-unhealthy-repository": "No se puede extraer un repositorio que no está en buen estado" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12219,15 @@ }, "warning-title-default": "Advertencia", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "La duración de este proceso depende del número de recursos involucrados.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12246,7 @@ "step-finish": "Elegir ajustes adicionales", "step-synchronize": "Sincronizar con un almacenamiento externo", "sync-description": "Sincroniza los recursos con un almacenamiento externo. Después de este paso único, todas las actualizaciones futuras se guardarán automáticamente en el repositorio y se aprovisionarán de nuevo en la instancia.", + "sync-option-migrate-resources": "", "title-bootstrap": "Elegir qué sincronizar", "title-connect": "Conectar a un almacenamiento externo", "title-finish": "Elegir ajustes adicionales", @@ -12460,16 +12484,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index d2faa93f8e5..1d98e007593 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -2666,6 +2666,35 @@ "text-federated": "Fédéré", "text-provisioned": "Mis en service" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Source de données", @@ -3717,6 +3746,7 @@ "text": "Aucun résultat n'a été trouvé pour votre requête" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6180,7 +6210,7 @@ "title-someone-else-has-updated-this-dashboard": "Quelqu’un d’autre a mis à jour ce tableau de bord", "would-still-dashboard": "Voulez-vous toujours enregistrer ce tableau de bord ?" }, - "save-and-overwrite": "« Enregistrer et écraser »" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7195,9 @@ "time-range-label": "Verrouiller la période temporelle" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Conseil de pro : {{proTip}}" }, @@ -7641,39 +7674,6 @@ }, "share-span": "Partager" }, - "span-filters": { - "aria-label-select-max-span-operator": "Sélectionner l’opérateur de la durée maximale", - "aria-label-select-min-span-operator": "Sélectionner l’opérateur de la durée minimale", - "aria-label-select-service-name": "Sélectionner le nom de service", - "aria-label-select-service-name-operator": "Sélectionner l’opérateur de nom de service", - "aria-label-select-span-name": "Sélectionner le nom de la durée", - "aria-label-select-span-name-operator": "Sélectionner l’opérateur de nom de la durée", - "ariaLabel-select-max-span-duration": "Sélectionner la durée maximale de segment", - "ariaLabel-select-min-span-duration": "Sélectionner la durée minimale de segment", - "label-collapse": "Filtres de durée", - "label-duration": "Durée", - "label-service-name": "Nom du service", - "label-span-name": "Nom de la durée", - "label-tags": "Étiquettes", - "placeholder-all-service-names": "Tous les noms de service", - "placeholder-all-span-names": "Tous les noms de durée", - "tooltip-collapse": "Filtrez vos plages ci-dessous. Vous pouvez continuer à appliquer des filtres jusqu’à ce que vous ayez réduit votre plage de résultats à ceux qui vous intéressent le plus.", - "tooltip-duration": "Filtrer par durée. Les unités acceptées sont {{units}}", - "tooltip-tags": "Filtrez par balises, balises de processus ou champs de journal dans vos durées." - }, - "span-filters-tags": { - "aria-label-add-tag": "Ajouter une balise", - "aria-label-input-tag-value": "Saisir la valeur de la balise", - "aria-label-remove-tag": "Supprimer la balise", - "aria-label-select-tag-key": "Sélectionner la clé de la balise", - "aria-label-select-tag-operator": "Sélectionner l’opérateur de la balise", - "aria-label-select-tag-value": "Sélectionner la valeur de la balise", - "placeholder-select-tag": "Choisir une balise", - "placeholder-select-value": "Sélectionner une valeur", - "placeholder-tag-value": "Valeur de la balise", - "tooltip-add-tag": "Ajouter une balise", - "tooltip-remove-tag": "Supprimer la balise" - }, "span-flame-graph": { "flame-graph": "Graphique de flamme" }, @@ -7872,23 +7872,18 @@ "label-upsample": "Sur-échantillonner", "tooltip-s-m-h": "10 s, 1 m, 30 m, 1 h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Exécuter la requête", - "modal-title": "Éditeur SQL", "tooltip-experimental": "L’intégration des expressions SQL avec les LLM est expérimentale. Merci de signaler tout problème à l’équipe Grafana." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Entrée" @@ -7963,11 +7958,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9384,7 @@ "name-unit": "Unité", "name-value-name": "Nom de la valeur", "name-y-axis-scale": "Échelle de l’axe Y", + "name-y-bucket-scale": "", "placeholder-axis-label": "Auto", "placeholder-axis-width": "Auto", "placeholder-decimals": "Auto", @@ -9422,6 +9414,18 @@ "label-all": "Tous", "label-hidden": "Masqué", "label-single": "Unique" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11207,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11981,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Afficher les détails", "loading-finished-job": "Chargement de mission terminée…", @@ -12078,6 +12090,9 @@ "webhook-last-event": "Dernier événement :", "webhook-url": "Voir le webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Retour aux référentiels", "cleaning-up-resources": "Nettoyage des ressources du référentiel", @@ -12182,6 +12197,9 @@ "tooltip-unhealthy-repository": "Impossible de fusionner un référentiel en mauvais état" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12219,15 @@ }, "warning-title-default": "Avertissement", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "La durée de ce processus dépend du nombre de ressources impliquées.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12246,7 @@ "step-finish": "Choisir des paramètres supplémentaires", "step-synchronize": "Synchroniser avec un stockage externe", "sync-description": "Synchronisez les ressources avec un stockage externe. Après cette étape unique, toutes les futures mises à jour seront automatiquement enregistrées dans le référentiel et mises en service dans l’instance.", + "sync-option-migrate-resources": "", "title-bootstrap": "Choisir ce qui doit être synchronisé", "title-connect": "Se connecter à un stockage externe", "title-finish": "Choisir des paramètres supplémentaires", @@ -12460,16 +12484,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index 0c8c164742b..bfe9d3e9542 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -2666,6 +2666,35 @@ "text-federated": "Összevont", "text-provisioned": "Kiépítve" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Adatforrás", @@ -3717,6 +3746,7 @@ "text": "Nincs találat a lekérdezésre" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6180,7 +6210,7 @@ "title-someone-else-has-updated-this-dashboard": "Valaki más frissítette ezt az irányítópultot", "would-still-dashboard": "Biztosan menti ezt az irányítópultot?" }, - "save-and-overwrite": "„Mentés és felülírás”" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7195,9 @@ "time-range-label": "Időtartomány zárolása" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "ProTip: {{proTip}}" }, @@ -7641,39 +7674,6 @@ }, "share-span": "Megosztás" }, - "span-filters": { - "aria-label-select-max-span-operator": "Max. terjedelem operátor kijelölése", - "aria-label-select-min-span-operator": "Válasszon operátort a min. terjedelemhez", - "aria-label-select-service-name": "Válasszon szolgáltatásnevet", - "aria-label-select-service-name-operator": "Válasszon operátort a szolgáltatásnévhez", - "aria-label-select-span-name": "Válasszon terjedelemnevet", - "aria-label-select-span-name-operator": "Terjedelemnév operátor kijelölése", - "ariaLabel-select-max-span-duration": "Maximális időtartam kiválasztása", - "ariaLabel-select-min-span-duration": "Minimális időtartam kiválasztása", - "label-collapse": "Terjedelemszűrők", - "label-duration": "Időtartam", - "label-service-name": "Szolgáltatásnév", - "label-span-name": "Terjedelemnév", - "label-tags": "Címkék", - "placeholder-all-service-names": "Összes szolgáltatásnév", - "placeholder-all-span-names": "Összes terjedelemnév", - "tooltip-collapse": "Alább szűrheti a terjedelmeket. Folytathatja a szűrők alkalmazását, amíg a kapott terjedelmeket a leginkább keresett néhány terjedelemre nem szűkíti.", - "tooltip-duration": "Szűrés időtartam szerint. Elfogadott mértékegységek: {{units}}", - "tooltip-tags": "Szűrés címkék, folyamatcímkék vagy naplómezők alapján a terjedelmeiben." - }, - "span-filters-tags": { - "aria-label-add-tag": "Címke hozzáadása", - "aria-label-input-tag-value": "Bemeneti címke értéke", - "aria-label-remove-tag": "Címke eltávolítása", - "aria-label-select-tag-key": "Címkekulcs kijelölése", - "aria-label-select-tag-operator": "Címkeoperátor kijelölése", - "aria-label-select-tag-value": "Címkeérték kijelölése", - "placeholder-select-tag": "Címke kijelölése", - "placeholder-select-value": "Érték kijelölése", - "placeholder-tag-value": "Címke értéke", - "tooltip-add-tag": "Címke hozzáadása", - "tooltip-remove-tag": "Címke eltávolítása" - }, "span-flame-graph": { "flame-graph": "Lángdiagram" }, @@ -7872,23 +7872,18 @@ "label-upsample": "Mintasűrűség növelése", "tooltip-s-m-h": "10 mp., 1 p., 30 p., 1 ó." }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Lekérdezés futtatása", - "modal-title": "SQL-szerkesztő", "tooltip-experimental": "Az SQL-kifejezések LLM-integrációja kísérleti jellegű. Kérjük, jelentse az esetleges problémákat a Grafana csapatának." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Bemenet" @@ -7963,11 +7958,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9384,7 @@ "name-unit": "Egység", "name-value-name": "Érték neve", "name-y-axis-scale": "Y tengely skálája", + "name-y-bucket-scale": "", "placeholder-axis-label": "Automatikus", "placeholder-axis-width": "Automatikus", "placeholder-decimals": "Automatikus", @@ -9422,6 +9414,18 @@ "label-all": "Összes", "label-hidden": "Rejtett", "label-single": "Különálló" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11207,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11981,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Részletek megtekintése", "loading-finished-job": "Befejezett feladat betöltése…", @@ -12078,6 +12090,9 @@ "webhook-last-event": "Legutóbbi esemény:", "webhook-url": "Webkapocs megtekintése" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Vissza az adattárakhoz", "cleaning-up-resources": "Adattári erőforrások tisztítása", @@ -12182,6 +12197,9 @@ "tooltip-unhealthy-repository": "Nem lehet beolvasni egy nem megfelelő állapotú adattárat" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12219,15 @@ }, "warning-title-default": "Figyelmeztetés", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "A folyamat időtartama az érintett erőforrások számától függ.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12246,7 @@ "step-finish": "Válassza ki a további beállításokat", "step-synchronize": "Szinkronizálás külső tárolóval", "sync-description": "Erőforrások szinkronizálása külső tárolóval. Ezután az egyszeri lépés után az összes jövőbeli frissítés automatikusan mentve lesz az adattárba, és vissza lesz építve a példányba.", + "sync-option-migrate-resources": "", "title-bootstrap": "Válassza ki, mit szeretne szinkronizálni", "title-connect": "Csatlakozás külső tárolóhoz", "title-finish": "Válassza ki a további beállításokat", @@ -12460,16 +12484,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index 66ad5574fb6..86b18767abb 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -2655,6 +2655,35 @@ "text-federated": "Federasi", "text-provisioned": "Disediakan" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Sumber data", @@ -3701,6 +3730,7 @@ "text": "Hasil untuk kueri Anda tidak ditemukan" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6159,7 +6189,7 @@ "title-someone-else-has-updated-this-dashboard": "Orang lain telah memperbarui dasbor ini", "would-still-dashboard": "Ingin tetap menyimpan dasbor ini?" }, - "save-and-overwrite": "'Simpan dan timpa'" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7142,6 +7172,9 @@ "time-range-label": "Kunci rentang waktu" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Kiat Pro: {{proTip}}" }, @@ -7616,39 +7649,6 @@ }, "share-span": "Bagikan" }, - "span-filters": { - "aria-label-select-max-span-operator": "Pilih operator rentang maksimum", - "aria-label-select-min-span-operator": "Pilih operator rentang min", - "aria-label-select-service-name": "Pilih nama layanan", - "aria-label-select-service-name-operator": "Pilih operator nama layanan", - "aria-label-select-span-name": "Pilih nama rentang", - "aria-label-select-span-name-operator": "Pilih operator nama rentang", - "ariaLabel-select-max-span-duration": "Pilih durasi rentang maksimum", - "ariaLabel-select-min-span-duration": "Pilih durasi rentang minimum", - "label-collapse": "Filter Rentang", - "label-duration": "Durasi", - "label-service-name": "Nama layanan", - "label-span-name": "Nama rentang", - "label-tags": "Tag", - "placeholder-all-service-names": "Semua nama layanan", - "placeholder-all-span-names": "Semua nama rentang", - "tooltip-collapse": "Filter rentang Anda di bawah ini. Anda dapat terus menerapkan filter hingga Anda mempersempit rentang hasil Anda menjadi beberapa pilihan yang paling Anda minati.", - "tooltip-duration": "Filter menurut durasi. Unit yang diterima adalah {{units}}", - "tooltip-tags": "Filter berdasarkan tag, tag proses, atau bidang log di rentang Anda." - }, - "span-filters-tags": { - "aria-label-add-tag": "Tambah tag", - "aria-label-input-tag-value": "Masukkan nilai tag", - "aria-label-remove-tag": "Hapus tag", - "aria-label-select-tag-key": "Pilih kunci tag", - "aria-label-select-tag-operator": "Pilih operator tag", - "aria-label-select-tag-value": "Pilih nilai tag", - "placeholder-select-tag": "Pilih tag", - "placeholder-select-value": "Pilih nilai", - "placeholder-tag-value": "Nilai tag", - "tooltip-add-tag": "Tambah tag", - "tooltip-remove-tag": "Hapus tag" - }, "span-flame-graph": { "flame-graph": "Grafik api" }, @@ -7847,23 +7847,18 @@ "label-upsample": "Tingkatkan sample", "tooltip-s-m-h": "10 dtk, 1 mnt, 30 mnt, 1 jm" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Jalankan kueri", - "modal-title": "Editor SQL", "tooltip-experimental": "Integrasi LLM Ekspresi SQL bersifat eksperimental. Harap laporkan masalah apa pun kepada tim Grafana." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Input" @@ -7938,11 +7933,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9358,6 +9349,7 @@ "name-unit": "Unit", "name-value-name": "Nama nilai", "name-y-axis-scale": "Skala sumbu Y", + "name-y-bucket-scale": "", "placeholder-axis-label": "Otomatis", "placeholder-axis-width": "Otomatis", "placeholder-decimals": "Otomatis", @@ -9387,6 +9379,18 @@ "label-all": "Semua", "label-hidden": "Tersembunyi", "label-single": "Tunggal" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11157,9 +11161,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11921,7 +11930,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Lihat detail", "loading-finished-job": "Memuat pekerjaan yang sudah selesai...", @@ -12027,6 +12039,9 @@ "webhook-last-event": "Peristiwa Terakhir:", "webhook-url": "Lihat Webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Kembali ke repositori", "cleaning-up-resources": "Membersihkan sumber daya repositori", @@ -12131,6 +12146,9 @@ "tooltip-unhealthy-repository": "Tidak dapat menerapkan pull pada repositori yang tidak sehat" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12150,10 +12168,15 @@ }, "warning-title-default": "Peringatan", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "Durasi proses ini bergantung pada jumlah sumber daya yang terlibat.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12172,6 +12195,7 @@ "step-finish": "Pilih pengaturan tambahan", "step-synchronize": "Sinkronkan dengan penyimpanan eksternal", "sync-description": "Sinkronkan sumber daya dengan penyimpanan eksternal. Setelah langkah satu kali ini, semua pembaruan mendatang akan disimpan secara otomatis ke repositori dan disediakan kembali ke instans.", + "sync-option-migrate-resources": "", "title-bootstrap": "Pilih item yang akan disinkronkan", "title-connect": "Hubungkan ke penyimpanan eksternal", "title-finish": "Pilih pengaturan tambahan", @@ -12408,16 +12432,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index 76898afd674..976d81b2f37 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -2666,6 +2666,35 @@ "text-federated": "Federato", "text-provisioned": "Fornito" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Sorgente dati", @@ -3717,6 +3746,7 @@ "text": "Nessun risultato trovato per la ricerca" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6180,7 +6210,7 @@ "title-someone-else-has-updated-this-dashboard": "Qualcun altro ha aggiornato questa dashboard", "would-still-dashboard": "Desideri comunque salvare questa dashboard?" }, - "save-and-overwrite": "\"Salva e sovrascrivi\"" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7195,9 @@ "time-range-label": "Blocca intervallo di tempo" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Suggerimento pro: {{proTip}} " }, @@ -7641,39 +7674,6 @@ }, "share-span": "Condividi" }, - "span-filters": { - "aria-label-select-max-span-operator": "Seleziona l'operatore di intervallo massimo", - "aria-label-select-min-span-operator": "Seleziona operatore di intervallo minimo", - "aria-label-select-service-name": "Seleziona nome servizio", - "aria-label-select-service-name-operator": "Seleziona operatore nome servizio", - "aria-label-select-span-name": "Seleziona nome intervallo", - "aria-label-select-span-name-operator": "Seleziona l'operatore del nome dell'intervallo", - "ariaLabel-select-max-span-duration": "Seleziona la durata dell'intervallo massimo", - "ariaLabel-select-min-span-duration": "Seleziona la durata dell'intervallo minimo", - "label-collapse": "Filtri intervallo", - "label-duration": "Durata", - "label-service-name": "Nome del servizio", - "label-span-name": "Nome intervallo", - "label-tags": "Tag", - "placeholder-all-service-names": "Tutti i nomi dei servizi", - "placeholder-all-span-names": "Tutti i nomi degli intervalli", - "tooltip-collapse": "Filtra i tuoi intervalli qui sotto. Puoi continuare ad applicare i filtri fino a quando non avrai ristretto gli intervalli risultanti ai pochi selezionati a cui sei più interessato.", - "tooltip-duration": "Filtra per durata. Le unità accettate sono {{units}}", - "tooltip-tags": "Filtra per tag, tag di processo o campi di registro nei tuoi intervalli." - }, - "span-filters-tags": { - "aria-label-add-tag": "Aggiungi tag", - "aria-label-input-tag-value": "Valore del tag di inserimento", - "aria-label-remove-tag": "Rimuovi tag", - "aria-label-select-tag-key": "Seleziona chiave tag", - "aria-label-select-tag-operator": "Seleziona operatore tag", - "aria-label-select-tag-value": "Seleziona valore tag", - "placeholder-select-tag": "Seleziona tag", - "placeholder-select-value": "Seleziona valore", - "placeholder-tag-value": "Valore del tag", - "tooltip-add-tag": "Aggiungi tag", - "tooltip-remove-tag": "Rimuovi tag" - }, "span-flame-graph": { "flame-graph": "Grafico a fiamma" }, @@ -7872,23 +7872,18 @@ "label-upsample": "Aumenta campionamento", "tooltip-s-m-h": "10 s, 1 m, 30 m, 1 h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Esegui query", - "modal-title": "Editor SQL", "tooltip-experimental": "L'integrazione LLM delle Espressioni SQL è sperimentale. Segnala eventuali problemi al team Grafana." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Inserisci" @@ -7963,11 +7958,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9384,7 @@ "name-unit": "Unità", "name-value-name": "Nome del valore", "name-y-axis-scale": "Scala asse Y", + "name-y-bucket-scale": "", "placeholder-axis-label": "Automatico", "placeholder-axis-width": "Automatico", "placeholder-decimals": "Automatico", @@ -9422,6 +9414,18 @@ "label-all": "Tutti", "label-hidden": "Nascosto", "label-single": "Singolo" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11207,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11981,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Visualizza dettagli", "loading-finished-job": "Caricamento attività terminata in corso...", @@ -12078,6 +12090,9 @@ "webhook-last-event": "Ultimo evento:", "webhook-url": "Visualizza webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Torna ai repository", "cleaning-up-resources": "Pulizia delle risorse del repository", @@ -12182,6 +12197,9 @@ "tooltip-unhealthy-repository": "Impossibile eseguire il pull di un repository non integro" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12219,15 @@ }, "warning-title-default": "Attenzione", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "La durata di questo processo dipende dal numero di risorse coinvolte.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12246,7 @@ "step-finish": "Scegli impostazioni aggiuntive", "step-synchronize": "Sincronizza con la memoria esterna", "sync-description": "Sincronizza le risorse con la memoria esterna. Dopo questo passaggio una tantum, tutti gli aggiornamenti futuri verranno salvati automaticamente nel repository e ripristinati nell'istanza.", + "sync-option-migrate-resources": "", "title-bootstrap": "Scegli cosa sincronizzare", "title-connect": "Connetti a una memoria esterna", "title-finish": "Scegli impostazioni aggiuntive", @@ -12460,16 +12484,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index cebb4cd6bad..9bfbc78a21e 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -2655,6 +2655,35 @@ "text-federated": "フェデレーション", "text-provisioned": "プロビジョニング済み" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "データソース", @@ -3701,6 +3730,7 @@ "text": "クエリに一致する結果が見つかりませんでした。" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6159,7 +6189,7 @@ "title-someone-else-has-updated-this-dashboard": "他のユーザーがこのダッシュボードを更新しました", "would-still-dashboard": "このダッシュボードの保存を続行しますか?" }, - "save-and-overwrite": "「保存して上書き」" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7142,6 +7172,9 @@ "time-range-label": "時間範囲をロック" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "プロのヒント:{{proTip}} " }, @@ -7616,39 +7649,6 @@ }, "share-span": "共有" }, - "span-filters": { - "aria-label-select-max-span-operator": "最大スパン演算子を選択", - "aria-label-select-min-span-operator": "最小スパン演算子を選択", - "aria-label-select-service-name": "サービス名を選択", - "aria-label-select-service-name-operator": "サービス名演算子を選択", - "aria-label-select-span-name": "スパン名を選択", - "aria-label-select-span-name-operator": "スパン名演算子を選択", - "ariaLabel-select-max-span-duration": "最大スパン期間を選択", - "ariaLabel-select-min-span-duration": "最小スパン期間を選択", - "label-collapse": "スパンフィルター", - "label-duration": "継続時間", - "label-service-name": "サービス名", - "label-span-name": "スパン名", - "label-tags": "タグ", - "placeholder-all-service-names": "すべてのサービス名", - "placeholder-all-span-names": "すべてのスパン名", - "tooltip-collapse": "以下のスパンを絞り込みます。最も関心のある少数のスパンに絞り込むまで、フィルターを適用し続けることができます。", - "tooltip-duration": "期間で絞り込みます。使用可能な単位:{{units}}", - "tooltip-tags": "スパン内のタグ、プロセスタグ、またはログフィールドで絞り込みます。" - }, - "span-filters-tags": { - "aria-label-add-tag": "タグを追加", - "aria-label-input-tag-value": "タグ値を入力", - "aria-label-remove-tag": "タグを削除", - "aria-label-select-tag-key": "タグキーを選択", - "aria-label-select-tag-operator": "タグ演算子を選択", - "aria-label-select-tag-value": "タグ値を選択", - "placeholder-select-tag": "タグを選択", - "placeholder-select-value": "値を選択", - "placeholder-tag-value": "タグ値", - "tooltip-add-tag": "タグを追加", - "tooltip-remove-tag": "タグを削除" - }, "span-flame-graph": { "flame-graph": "フレームグラフ" }, @@ -7847,23 +7847,18 @@ "label-upsample": "アップサンプリング", "tooltip-s-m-h": "10秒、1分、30分、1時間" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "クエリを実行", - "modal-title": "SQLエディター", "tooltip-experimental": "SQL Expressions LLMの統合は実験的です。問題が発生した場合は、Grafanaチームに報告してください。" }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "入力" @@ -7938,11 +7933,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9358,6 +9349,7 @@ "name-unit": "単位", "name-value-name": "値の名前", "name-y-axis-scale": "Y軸スケール", + "name-y-bucket-scale": "", "placeholder-axis-label": "自動", "placeholder-axis-width": "自動", "placeholder-decimals": "自動", @@ -9387,6 +9379,18 @@ "label-all": "すべて", "label-hidden": "非表示", "label-single": "単体" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11157,9 +11161,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11921,7 +11930,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "詳細を表示", "loading-finished-job": "完了したジョブを読み込み中...", @@ -12027,6 +12039,9 @@ "webhook-last-event": "最新イベント:", "webhook-url": "Webhookを表示" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "リポジトリに戻る", "cleaning-up-resources": "リポジトリリソースのクリーンアップ中", @@ -12131,6 +12146,9 @@ "tooltip-unhealthy-repository": "問題のあるリポジトリをプルできません" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12150,10 +12168,15 @@ }, "warning-title-default": "警告", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "このプロセスの所要時間は、関連するリソースの数によって異なります。", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12172,6 +12195,7 @@ "step-finish": "追加設定を選択", "step-synchronize": "外部ストレージと同期", "sync-description": "リソースを外部ストレージと同期します。この一度限りの手順が完了すると、その後のすべての更新は自動的にリポジトリに保存され、インスタンスにプロビジョニングされます。", + "sync-option-migrate-resources": "", "title-bootstrap": "同期する内容を選択", "title-connect": "外部ストレージに接続", "title-finish": "追加設定を選択", @@ -12408,16 +12432,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index 07e410e67e1..bd4103a1de1 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -2655,6 +2655,35 @@ "text-federated": "연합됨", "text-provisioned": "프로비저닝됨" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "데이터 소스", @@ -3701,6 +3730,7 @@ "text": "쿼리에 대해 찾은 결과 없음" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6159,7 +6189,7 @@ "title-someone-else-has-updated-this-dashboard": "다른 사람이 이 대시보드를 업데이트했습니다", "would-still-dashboard": "그래도 이 대시보드를 저장하시겠어요?" }, - "save-and-overwrite": "'저장 및 덮어쓰기'" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7142,6 +7172,9 @@ "time-range-label": "시간 범위 잠그기" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "유용한 팁: {{proTip}} " }, @@ -7616,39 +7649,6 @@ }, "share-span": "공유" }, - "span-filters": { - "aria-label-select-max-span-operator": "최대 스팬 연산자 선택", - "aria-label-select-min-span-operator": "최소 스팬 연산자 선택", - "aria-label-select-service-name": "서비스 이름 선택", - "aria-label-select-service-name-operator": "서비스 이름 연산자 선택", - "aria-label-select-span-name": "스팬 이름 선택", - "aria-label-select-span-name-operator": "스팬 이름 연산자 선택", - "ariaLabel-select-max-span-duration": "최대 스팬 기간 선택", - "ariaLabel-select-min-span-duration": "최소 스팬 기간 선택", - "label-collapse": "스팬 필터", - "label-duration": "지속 시간", - "label-service-name": "서비스 이름", - "label-span-name": "스팬 이름", - "label-tags": "태그", - "placeholder-all-service-names": "모든 서비스 이름", - "placeholder-all-span-names": "모든 스팬 이름", - "tooltip-collapse": "아래에서 스팬을 필터링합니다. 결과 스팬을 가장 관심 있는 몇 가지로 좁힐 때까지 필터를 계속 적용할 수 있습니다.", - "tooltip-duration": "지속 시간을 기준으로 필터링합니다. 허용되는 단위는 {{units}}입니다", - "tooltip-tags": "스팬의 태그, 프로세스 태그 또는 로그 필드를 기준으로 필터링합니다." - }, - "span-filters-tags": { - "aria-label-add-tag": "태그 추가", - "aria-label-input-tag-value": "태그 값 입력", - "aria-label-remove-tag": "태그 제거", - "aria-label-select-tag-key": "태그 키 선택", - "aria-label-select-tag-operator": "태그 연산자 선택", - "aria-label-select-tag-value": "태그 값 선택", - "placeholder-select-tag": "태그 선택", - "placeholder-select-value": "값 선택", - "placeholder-tag-value": "태그 값", - "tooltip-add-tag": "태그 추가", - "tooltip-remove-tag": "태그 제거" - }, "span-flame-graph": { "flame-graph": "불꽃 그래프" }, @@ -7847,23 +7847,18 @@ "label-upsample": "업샘플", "tooltip-s-m-h": "10초, 1분, 30분, 1시간" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "쿼리 실행", - "modal-title": "SQL 편집기", "tooltip-experimental": "SQL 표현식 LLM 통합 기능은 실험 단계입니다. 문제가 발생하면 Grafana 팀에 보고해 주세요." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "입력" @@ -7938,11 +7933,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9358,6 +9349,7 @@ "name-unit": "단위", "name-value-name": "값 이름", "name-y-axis-scale": "Y축 스케일", + "name-y-bucket-scale": "", "placeholder-axis-label": "자동", "placeholder-axis-width": "자동", "placeholder-decimals": "자동", @@ -9387,6 +9379,18 @@ "label-all": "전체", "label-hidden": "숨김", "label-single": "단일" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11157,9 +11161,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11921,7 +11930,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "세부 정보 보기", "loading-finished-job": "완료된 작업 로딩 중...", @@ -12027,6 +12039,9 @@ "webhook-last-event": "마지막 이벤트:", "webhook-url": "웹훅 보기" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "리포지토리로 돌아가기", "cleaning-up-resources": "리포지토리 리소스 정리 및 삭제 중", @@ -12131,6 +12146,9 @@ "tooltip-unhealthy-repository": "상태가 좋지 않은 리포지토리를 가져올 수 없습니다" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12150,10 +12168,15 @@ }, "warning-title-default": "경고", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "이 프로세스의 지속 시간은 관련된 리소스 수에 따라 달라집니다.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12172,6 +12195,7 @@ "step-finish": "추가 설정 선택", "step-synchronize": "외부 스토리지와 동기화", "sync-description": "외부 스토리지와 리소스를 동기화합니다. 이 일회성 단계 후에는 향후 모든 업데이트가 자동으로 리포지토리에 저장되고 인스턴스에 다시 프로비저닝됩니다.", + "sync-option-migrate-resources": "", "title-bootstrap": "동기화할 항목 선택", "title-connect": "외부 스토리지에 연결", "title-finish": "추가 설정 선택", @@ -12408,16 +12432,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index 237fbb74953..d5386284647 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -2666,6 +2666,35 @@ "text-federated": "Federatief", "text-provisioned": "Provisioned" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Gegevensbron", @@ -3717,6 +3746,7 @@ "text": "Geen resultaten gevonden voor je zoekopdracht" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6180,7 +6210,7 @@ "title-someone-else-has-updated-this-dashboard": "Iemand anders heeft dit dashboard bijgewerkt", "would-still-dashboard": "Wil je dit dashboard nog steeds opslaan?" }, - "save-and-overwrite": "'Opslaan en overschrijven'" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7195,9 @@ "time-range-label": "Tijdsbereik vergrendelen" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "ProTip: {{proTip}} " }, @@ -7641,39 +7674,6 @@ }, "share-span": "Delen" }, - "span-filters": { - "aria-label-select-max-span-operator": "Operator voor maximale span selecteren", - "aria-label-select-min-span-operator": "Selecteer een operator voor een minimumspan", - "aria-label-select-service-name": "Servicenaam selecteren", - "aria-label-select-service-name-operator": "Servicenaam operator selecteren", - "aria-label-select-span-name": "Selecteer een spannaam", - "aria-label-select-span-name-operator": " Operator voor een spannaam selecteren", - "ariaLabel-select-max-span-duration": "Selecteer een maximale spanduur", - "ariaLabel-select-min-span-duration": "Selecteer een minimale spanduur", - "label-collapse": "Spanfilters", - "label-duration": "Duur", - "label-service-name": "Servicenaam", - "label-span-name": "Spannaam", - "label-tags": "Labels", - "placeholder-all-service-names": "Alle servicenamen", - "placeholder-all-span-names": "Alle spannamen", - "tooltip-collapse": "Filter je bereik hieronder. Je kunt filters blijven toepassen totdat je het resulterende bereik hebt beperkt tot de resultaten die het meest interessant voor je zijn.", - "tooltip-duration": "Filteren op duur. Geaccepteerde eenheden zijn {{units}}", - "tooltip-tags": "Filteren op labels, labels verwerken of logvelden in je spans." - }, - "span-filters-tags": { - "aria-label-add-tag": "Label toevoegen", - "aria-label-input-tag-value": "Labelwaarde invoeren", - "aria-label-remove-tag": "Label verwijderen", - "aria-label-select-tag-key": "Labelsleutel selecteren", - "aria-label-select-tag-operator": "Labeloperator selecteren", - "aria-label-select-tag-value": "Labelwaarde selecteren", - "placeholder-select-tag": "Label selecteren", - "placeholder-select-value": "Waarde selecteren", - "placeholder-tag-value": "Labelwaarde", - "tooltip-add-tag": "Label toevoegen", - "tooltip-remove-tag": "Label verwijderen" - }, "span-flame-graph": { "flame-graph": "Vlamgrafiek" }, @@ -7872,23 +7872,18 @@ "label-upsample": "Upsample", "tooltip-s-m-h": "10s, 1m, 30m, 1u" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Query uitvoeren", - "modal-title": "SQL-editor", "tooltip-experimental": "SQL Expressions LLM-integratie is experimenteel. Meld eventuele problemen aan het Grafana-team." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Invoer" @@ -7963,11 +7958,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9384,7 @@ "name-unit": "Eenheid", "name-value-name": "Naam van waarde:", "name-y-axis-scale": "Y-as schaal", + "name-y-bucket-scale": "", "placeholder-axis-label": "Automatisch", "placeholder-axis-width": "Automatisch", "placeholder-decimals": "Automatisch", @@ -9422,6 +9414,18 @@ "label-all": "Alle", "label-hidden": "Verborgen", "label-single": "Enkel" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11207,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11981,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Details bekijken", "loading-finished-job": "Voltooide taak laden...", @@ -12078,6 +12090,9 @@ "webhook-last-event": "Laatste gebeurtenis:", "webhook-url": "Webhook bekijken" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Terug naar repositories", "cleaning-up-resources": "Bronnen van repository opschonen", @@ -12182,6 +12197,9 @@ "tooltip-unhealthy-repository": "Kan geen ongezonde repository ophalen" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12219,15 @@ }, "warning-title-default": "Waarschuwing", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "De duur van dit proces is afhankelijk van het aantal betrokken bronnen.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12246,7 @@ "step-finish": "Kies aanvullende instellingen", "step-synchronize": "Synchroniseren met externe opslag", "sync-description": "Bronnen synchroniseren met externe opslag. Na deze eenmalige stap worden alle toekomstige updates automatisch opgeslagen in de repository en opnieuw ingericht in de instantie.", + "sync-option-migrate-resources": "", "title-bootstrap": "Kies wat je wilt synchroniseren", "title-connect": "Verbinden met externe opslag", "title-finish": "Kies aanvullende instellingen", @@ -12460,16 +12484,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index 6ff40c3ab1f..ad8f9b19b6a 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -2688,6 +2688,35 @@ "text-federated": "Federacja", "text-provisioned": "Po aprowizacji" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Źródło danych", @@ -3749,6 +3778,7 @@ "text": "Nie znaleziono wyników dla tego zapytania" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6222,7 +6252,7 @@ "title-someone-else-has-updated-this-dashboard": "Ktoś inny zaktualizował ten pulpit", "would-still-dashboard": "Czy nadal chcesz zapisać ten pulpit?" }, - "save-and-overwrite": "„Zapisz i zastąp”" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7211,6 +7241,9 @@ "time-range-label": "Zablokuj zakres czasu" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Wskazówka: {{proTip}}" }, @@ -7691,39 +7724,6 @@ }, "share-span": "Udostępnij" }, - "span-filters": { - "aria-label-select-max-span-operator": "Wybierz operator maksymalnego zakresu", - "aria-label-select-min-span-operator": "Wybierz operator minimalnego zakresu", - "aria-label-select-service-name": "Wybierz nazwę usługi", - "aria-label-select-service-name-operator": "Wybierz operator nazwy usługi", - "aria-label-select-span-name": "Wybierz nazwę zakresu", - "aria-label-select-span-name-operator": "Wybierz operator nazwy zakresu", - "ariaLabel-select-max-span-duration": "Wybierz maksymalny czas trwania zakresu", - "ariaLabel-select-min-span-duration": "Wybierz minimalny czas trwania zakresu", - "label-collapse": "Filtry zakresu", - "label-duration": "Czas trwania", - "label-service-name": "Nazwa usługi", - "label-span-name": "Nazwa zakresu", - "label-tags": "Znaczniki", - "placeholder-all-service-names": "Wszystkie nazwy usług", - "placeholder-all-span-names": "Wszystkie nazwy zakresów", - "tooltip-collapse": "Odfiltruj zakresy poniżej. Możesz dodawać filtry, aż zawęzisz zakres wyników do kilku najbardziej interesujących.", - "tooltip-duration": "Filtrowanie według czasu trwania. Akceptowane jednostki: {{units}}", - "tooltip-tags": "Filtruj według tagów, tagów procesu lub pól logów w swoich zakresach." - }, - "span-filters-tags": { - "aria-label-add-tag": "Dodaj tag", - "aria-label-input-tag-value": "Wpisz wartość tagu", - "aria-label-remove-tag": "Usuń tag", - "aria-label-select-tag-key": "Wybierz klucz tagu", - "aria-label-select-tag-operator": "Wybierz operator tagu", - "aria-label-select-tag-value": "Wybierz wartość tagu", - "placeholder-select-tag": "Wybierz tag", - "placeholder-select-value": "Wybierz wartość", - "placeholder-tag-value": "Wartość tagu", - "tooltip-add-tag": "Dodaj tag", - "tooltip-remove-tag": "Usuń tag" - }, "span-flame-graph": { "flame-graph": "Wykres płomienia" }, @@ -7922,23 +7922,18 @@ "label-upsample": "Zwiększ próbkowanie", "tooltip-s-m-h": "10s, 1m, 30m, 1h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Uruchom zapytanie", - "modal-title": "Edytor SQL", "tooltip-experimental": "Integracja LLM z wyrażeniami SQL jest eksperymentalna. Wszelkie problemy zgłaszaj zespołowi Grafana." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Wejście" @@ -8013,11 +8008,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9463,6 +9454,7 @@ "name-unit": "Jednostka", "name-value-name": "Nazwa wartości", "name-y-axis-scale": "Skala osi Y", + "name-y-bucket-scale": "", "placeholder-axis-label": "Automatycznie", "placeholder-axis-width": "Automatycznie", "placeholder-decimals": "Automatycznie", @@ -9492,6 +9484,18 @@ "label-all": "Wszystkie", "label-hidden": "Ukryte", "label-single": "Pojedyncza" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11295,9 +11299,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -12074,7 +12083,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Zobacz szczegóły", "loading-finished-job": "Wczytywanie zakończonego zadania…", @@ -12180,6 +12192,9 @@ "webhook-last-event": "Ostatnie zdarzenie:", "webhook-url": "Wyświetl element webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Wróć do repozytoriów", "cleaning-up-resources": "Sprzątanie zasobów repozytorium", @@ -12284,6 +12299,9 @@ "tooltip-unhealthy-repository": "Nie można pobrać danych z niesprawnego repozytorium" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12303,10 +12321,15 @@ }, "warning-title-default": "Ostrzeżenie", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "Czas trwania tego procesu zależy od liczby zasobów.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12325,6 +12348,7 @@ "step-finish": "Wybierz dodatkowe ustawienia", "step-synchronize": "Synchronizuj z zewnętrzną pamięcią masową", "sync-description": "Zsynchronizuj zasoby z zewnętrzną pamięcią masową. Po tym jednorazowym kroku wszystkie przyszłe aktualizacje zostaną automatycznie zapisane w repozytorium i ponownie aprowizowane w instancji.", + "sync-option-migrate-resources": "", "title-bootstrap": "Wybierz, co chcesz zsynchronizować", "title-connect": "Połączenie z zewnętrzną pamięcią masową", "title-finish": "Wybierz dodatkowe ustawienia", @@ -12564,16 +12588,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index cbaee920be0..8ad480fc30d 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -2666,6 +2666,35 @@ "text-federated": "Federado", "text-provisioned": "Provisionado" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Fonte de dados", @@ -3717,6 +3746,7 @@ "text": "Nenhum resultado encontrado para sua consulta" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6180,7 +6210,7 @@ "title-someone-else-has-updated-this-dashboard": "Outra pessoa atualizou este painel", "would-still-dashboard": "Deseja salvar este painel mesmo assim?" }, - "save-and-overwrite": "\"Salvar e substituir\"" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7195,9 @@ "time-range-label": "Bloquear intervalo de tempo" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Dica de especialistas: {{proTip}}" }, @@ -7641,39 +7674,6 @@ }, "share-span": "Compartilhar" }, - "span-filters": { - "aria-label-select-max-span-operator": "Selecionar operador de intervalo máximo", - "aria-label-select-min-span-operator": "Selecionar operador de intervalo mínimo", - "aria-label-select-service-name": "Selecionar nome do serviço", - "aria-label-select-service-name-operator": "Selecionar operador de nome do serviço", - "aria-label-select-span-name": "Selecionar nome do intervalo", - "aria-label-select-span-name-operator": "Selecione o operador de nome de intervalo", - "ariaLabel-select-max-span-duration": "Selecionar duração máxima do intervalo", - "ariaLabel-select-min-span-duration": "Selecionar duração mínima do intervalo", - "label-collapse": "Filtros de intervalo", - "label-duration": "Duração", - "label-service-name": "Nome do serviço", - "label-span-name": "Nome do intervalo", - "label-tags": "Tags", - "placeholder-all-service-names": "Todos os nomes de serviço", - "placeholder-all-span-names": "Todos os nomes de intervalo", - "tooltip-collapse": "Filtre seus intervalos abaixo. Você pode continuar aplicando filtros até restringir seus intervalos resultantes a um grupo pequeno que contenha aqueles que forem mais pertinentes para você.", - "tooltip-duration": "Filtrar por duração. As unidades aceitas são {{units}}", - "tooltip-tags": "Filtrar por tags, tags de processo ou campos de logs nos seus intervalos." - }, - "span-filters-tags": { - "aria-label-add-tag": "Adicionar tag", - "aria-label-input-tag-value": "Valor da tag de entrada", - "aria-label-remove-tag": "Remover tag", - "aria-label-select-tag-key": "Selecionar chave de tag", - "aria-label-select-tag-operator": "Selecionar operador de tag", - "aria-label-select-tag-value": "Selecionar valor da tag", - "placeholder-select-tag": "Selecionar tag", - "placeholder-select-value": "Selecionar valor", - "placeholder-tag-value": "Valor da tag", - "tooltip-add-tag": "Adicionar tag", - "tooltip-remove-tag": "Remover tag" - }, "span-flame-graph": { "flame-graph": "Gráfico de chama" }, @@ -7872,23 +7872,18 @@ "label-upsample": "Aumentar taxa de amostragem", "tooltip-s-m-h": "10s, 1m, 30m, 1h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Executar consulta", - "modal-title": "Editor de SQL", "tooltip-experimental": "A integração do LLM de expressões SQL está em fase de testes. Informe à equipe do Grafana se surgir algum problema." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Entrada" @@ -7963,11 +7958,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9384,7 @@ "name-unit": "Unidade", "name-value-name": "Nome do valor", "name-y-axis-scale": "Escala do eixo Y", + "name-y-bucket-scale": "", "placeholder-axis-label": "Automático", "placeholder-axis-width": "Automático", "placeholder-decimals": "Automático", @@ -9422,6 +9414,18 @@ "label-all": "Tudo", "label-hidden": "Oculto", "label-single": "Única" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11207,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11981,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Veja os detalhes", "loading-finished-job": "Carregando tarefa concluída…", @@ -12078,6 +12090,9 @@ "webhook-last-event": "Último evento:", "webhook-url": "Visualizar Webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Voltar para os repositórios", "cleaning-up-resources": "Limpando recursos do repositório", @@ -12182,6 +12197,9 @@ "tooltip-unhealthy-repository": "Não é possível fazer extração de um repositório instável" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12219,15 @@ }, "warning-title-default": "Aviso", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "A duração deste processo depende da quantidade de recursos envolvidos.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12246,7 @@ "step-finish": "Escolha configurações adicionais", "step-synchronize": "Sincronizar com armazenamento externo", "sync-description": "Sincronize recursos com armazenamento externo. Após esta única etapa, todas as atualizações futuras serão salvas automaticamente no repositório e provisionadas de volta para a instância.", + "sync-option-migrate-resources": "", "title-bootstrap": "Escolha o que será sincronizado", "title-connect": "Conectar ao armazenamento externo", "title-finish": "Escolha configurações adicionais", @@ -12460,16 +12484,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index 4e99ebc24f7..3fed004bfdf 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -2666,6 +2666,35 @@ "text-federated": "Federado", "text-provisioned": "Aprovisionado" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Origem dos dados", @@ -3717,6 +3746,7 @@ "text": "Não foram encontrados resultados para a sua consulta" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6180,7 +6210,7 @@ "title-someone-else-has-updated-this-dashboard": "Outra pessoa atualizou este painel de controlo", "would-still-dashboard": "Ainda pretende guardar este painel de controlo?" }, - "save-and-overwrite": "\"Guardar e substituir\"" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7195,9 @@ "time-range-label": "Bloquear intervalo de tempo" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Dica Pro: {{proTip}}" }, @@ -7641,39 +7674,6 @@ }, "share-span": "Partilhar" }, - "span-filters": { - "aria-label-select-max-span-operator": "Selecionar o operador de intervalo máximo", - "aria-label-select-min-span-operator": "Selecionar o operador de intervalo mínimo", - "aria-label-select-service-name": "Selecione o nome do serviço", - "aria-label-select-service-name-operator": "Selecionar o operador do nome do serviço", - "aria-label-select-span-name": "Selecionar o nome do intervalo", - "aria-label-select-span-name-operator": "Selecionar o operador do nome do intervalo", - "ariaLabel-select-max-span-duration": "Selecionar a duração máxima do intervalo", - "ariaLabel-select-min-span-duration": "Selecionar a duração mínima do intervalo", - "label-collapse": "Filtros de intervalo", - "label-duration": "Duração", - "label-service-name": "Nome do serviço", - "label-span-name": "Nome do intervalo", - "label-tags": "Etiquetas", - "placeholder-all-service-names": "Todos os nomes de serviços", - "placeholder-all-span-names": "Todos os nomes de intervalos", - "tooltip-collapse": "Filtre os seus intervalos abaixo. Pode continuar a aplicar filtros até ter limitado os seus períodos resultantes para apenas alguns que lhe interessem mais.", - "tooltip-duration": "Filtrar por duração. As unidades aceites são {{units}}", - "tooltip-tags": "Filtrar por etiquetas, etiquetas de processo ou campos de registo nos seus intervalos." - }, - "span-filters-tags": { - "aria-label-add-tag": "Adicionar etiqueta", - "aria-label-input-tag-value": "Valor da etiqueta de entrada", - "aria-label-remove-tag": "Remover controlo", - "aria-label-select-tag-key": "Selecionar a chave da etiqueta", - "aria-label-select-tag-operator": "Selecionar o operador da etiqueta", - "aria-label-select-tag-value": "Selecionar o valor da etiqueta", - "placeholder-select-tag": "Selecionar a etiqueta", - "placeholder-select-value": "Selecionar valor", - "placeholder-tag-value": "Valor da etiqueta", - "tooltip-add-tag": "Adicionar etiqueta", - "tooltip-remove-tag": "Remover controlo" - }, "span-flame-graph": { "flame-graph": "Gráfico de chama" }, @@ -7872,23 +7872,18 @@ "label-upsample": "Aumentar a quantidade de amostras", "tooltip-s-m-h": "10s, 1m, 30m, 1h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Executar consulta", - "modal-title": "Editor SQL", "tooltip-experimental": "A integração de LLM de expressões SQL é experimental. Comunique quaisquer problemas à equipa da Grafana." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Entrada" @@ -7963,11 +7958,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9384,7 @@ "name-unit": "Unidade", "name-value-name": "Nome do valor", "name-y-axis-scale": "Escala do eixo Y", + "name-y-bucket-scale": "", "placeholder-axis-label": "Automático", "placeholder-axis-width": "Automático", "placeholder-decimals": "Automático", @@ -9422,6 +9414,18 @@ "label-all": "Tudo", "label-hidden": "Oculto", "label-single": "Único" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11207,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11981,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Ver detalhes", "loading-finished-job": "A carregar o trabalho concluído...", @@ -12078,6 +12090,9 @@ "webhook-last-event": "Último evento:", "webhook-url": "Visualizar Webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Voltar aos repositórios", "cleaning-up-resources": "A limpar recursos do repositório", @@ -12182,6 +12197,9 @@ "tooltip-unhealthy-repository": "Não foi possível obter um repositório que não está em bom estado" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12219,15 @@ }, "warning-title-default": "Aviso", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "A duração deste processo depende do número de recursos envolvidos.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12246,7 @@ "step-finish": "Escolha as definições adicionais", "step-synchronize": "Sincronizar com armazenamento externo", "sync-description": "Sincronize recursos com um armazenamento externo. Após este passo único, todas as atualizações futuras serão guardadas automaticamente no repositório e aprovisionadas novamente para a instância.", + "sync-option-migrate-resources": "", "title-bootstrap": "Escolha o que sincronizar", "title-connect": "Ligar a um armazenamento externo", "title-finish": "Escolha as definições adicionais", @@ -12460,16 +12484,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index 3d29410a1bf..10cd0cdd7bb 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -2688,6 +2688,35 @@ "text-federated": "Федеративная", "text-provisioned": "Подготовлено" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Источник данных", @@ -3749,6 +3778,7 @@ "text": "По вашему запросу ничего не найдено" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6222,7 +6252,7 @@ "title-someone-else-has-updated-this-dashboard": "Дашборд обновлен другим пользователем", "would-still-dashboard": "Все равно сохранить дашборд?" }, - "save-and-overwrite": "'Сохранить и перезаписать'" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7211,6 +7241,9 @@ "time-range-label": "Заблокировать временной диапазон" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Совет: {{proTip}}" }, @@ -7691,39 +7724,6 @@ }, "share-span": "Поделиться" }, - "span-filters": { - "aria-label-select-max-span-operator": "Выбрать оператора макс. диапазона", - "aria-label-select-min-span-operator": "Выбрать оператор мин. диапазона", - "aria-label-select-service-name": "Выбрать название службы", - "aria-label-select-service-name-operator": "Выбрать оператор названия службы", - "aria-label-select-span-name": "Выбрать название диапазона", - "aria-label-select-span-name-operator": "Выбрать оператора названия диапазона", - "ariaLabel-select-max-span-duration": "Выбрать макс. продолжительность интервала", - "ariaLabel-select-min-span-duration": "Выбрать мин. продолжительность интервала", - "label-collapse": "Фильтры диапазонов", - "label-duration": "Длительность", - "label-service-name": "Название службы", - "label-span-name": "Название диапазона", - "label-tags": "Теги", - "placeholder-all-service-names": "Все названия служб", - "placeholder-all-span-names": "Все названия диапазонов", - "tooltip-collapse": "Выполните фильтрацию своих диапазонов ниже. Вы можете продолжать применять фильтры, пока не сузите полученные диапазоны до нескольких наиболее важных для вас вариантов.", - "tooltip-duration": "Фильтр по длительности. Допустимые единицы измерения: {{units}}", - "tooltip-tags": "Фильтр по тегам, тегам процессов или полям журнала в ваших диапазонах." - }, - "span-filters-tags": { - "aria-label-add-tag": "Добавить тег", - "aria-label-input-tag-value": "Ввести значение тега", - "aria-label-remove-tag": "Удалить тег", - "aria-label-select-tag-key": "Выбрать ключ тега", - "aria-label-select-tag-operator": "Выбрать оператор тега", - "aria-label-select-tag-value": "Выбрать значение тега", - "placeholder-select-tag": "Выбрать тег", - "placeholder-select-value": "Выбрать значение", - "placeholder-tag-value": "Значение тега", - "tooltip-add-tag": "Добавить тег", - "tooltip-remove-tag": "Удалить тег" - }, "span-flame-graph": { "flame-graph": "Flame-график" }, @@ -7922,23 +7922,18 @@ "label-upsample": "Увеличить частоту выборки", "tooltip-s-m-h": "10 с, 1 мин, 30 мин, 1 ч" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Выполнить запрос", - "modal-title": "Редактор SQL", "tooltip-experimental": "Интеграция LLM с SQL-выражениями является экспериментальной. При обнаружении проблем свяжитесь с командой Grafana." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Ввод" @@ -8013,11 +8008,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9463,6 +9454,7 @@ "name-unit": "Единица", "name-value-name": "Имя значения", "name-y-axis-scale": "Шкала оси Y", + "name-y-bucket-scale": "", "placeholder-axis-label": "Авто", "placeholder-axis-width": "Авто", "placeholder-decimals": "Авто", @@ -9492,6 +9484,18 @@ "label-all": "Все", "label-hidden": "Скрыты", "label-single": "Один" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11295,9 +11299,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -12074,7 +12083,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Подробнее", "loading-finished-job": "Загрузка завершенного задания...", @@ -12180,6 +12192,9 @@ "webhook-last-event": "Последнее событие:", "webhook-url": "Просмотр веб-перехватчика" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Назад к репозиториям", "cleaning-up-resources": "Очистка ресурсов репозитория", @@ -12284,6 +12299,9 @@ "tooltip-unhealthy-repository": "Невозможно внести изменения в неисправный репозиторий" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12303,10 +12321,15 @@ }, "warning-title-default": "Предупреждение", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "Длительность процесса зависит от количества задействованных ресурсов.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12325,6 +12348,7 @@ "step-finish": "Выберите дополнительные параметры", "step-synchronize": "Синхронизировать с внешним хранилищем", "sync-description": "Синхронизируйте ресурсы с внешним хранилищем. После этого разового шага все будущие обновления будут автоматически сохраняться в репозитории и загружаться обратно в экземпляр.", + "sync-option-migrate-resources": "", "title-bootstrap": "Выберите, что синхронизировать", "title-connect": "Подключение к внешнему хранилищу", "title-finish": "Выбор дополнительных параметров", @@ -12564,16 +12588,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index 38943db06ed..faca6a40afc 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -2666,6 +2666,35 @@ "text-federated": "Federerade", "text-provisioned": "Provisionerad" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Datakälla", @@ -3717,6 +3746,7 @@ "text": "Inga resultat hittades för din fråga" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6180,7 +6210,7 @@ "title-someone-else-has-updated-this-dashboard": "Någon annan har uppdaterat denna instrumentpanel", "would-still-dashboard": "Vill du fortfarande spara denna instrumentpanel?" }, - "save-and-overwrite": "”Spara och skriv över”" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7195,9 @@ "time-range-label": "Lås tidsintervall" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "ProTip: {{proTip}}" }, @@ -7641,39 +7674,6 @@ }, "share-span": "Dela" }, - "span-filters": { - "aria-label-select-max-span-operator": "Välj operator för maximalt intervall", - "aria-label-select-min-span-operator": "Välj operator för minsta intervall", - "aria-label-select-service-name": "Välj namn för tjänst", - "aria-label-select-service-name-operator": "Välj operator för tjänstnamn", - "aria-label-select-span-name": "Välj namn på intervall", - "aria-label-select-span-name-operator": "Välj operator för intervallnamn", - "ariaLabel-select-max-span-duration": "Välj maximal varaktighet för tidsspann", - "ariaLabel-select-min-span-duration": "Välj minimal varaktighet för tidsspann", - "label-collapse": "Intervallfilter", - "label-duration": "Varaktighet", - "label-service-name": "Namn på tjänst", - "label-span-name": "Intervallnamn", - "label-tags": "Taggar", - "placeholder-all-service-names": "Alla servicenamn", - "placeholder-all-span-names": "Alla namn på intervall", - "tooltip-collapse": "Filtrera dina spann nedan. Du kan fortsätta att tillämpa filter tills du har begränsat dina resulterande spann till det fåtal som du är mest intresserad av.", - "tooltip-duration": "Filtrera per varaktighet. Accepterade enheter är {{units}}", - "tooltip-tags": "Filtrera efter taggar, processtaggar eller loggfält i dina spår." - }, - "span-filters-tags": { - "aria-label-add-tag": "Lägg till etikett", - "aria-label-input-tag-value": "Ange taggvärde", - "aria-label-remove-tag": "Ta bort tagg", - "aria-label-select-tag-key": "Välj etikettnyckel", - "aria-label-select-tag-operator": "Välj etikettoperator", - "aria-label-select-tag-value": "Välj etikettvärde", - "placeholder-select-tag": "Välj etikett", - "placeholder-select-value": "Välj värde", - "placeholder-tag-value": "Etikettvärde", - "tooltip-add-tag": "Lägg till etikett", - "tooltip-remove-tag": "Ta bort tagg" - }, "span-flame-graph": { "flame-graph": "Flamgraf" }, @@ -7872,23 +7872,18 @@ "label-upsample": "Sampla upp", "tooltip-s-m-h": "10 s, 1 m, 30 m, 1 h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Kör fråga", - "modal-title": "SQL-redigerare", "tooltip-experimental": "Integreringen för SQL Expressions LLM är i ett experimentellt skede. Rapportera alla problem du stöter på till Grafana-teamet." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Ingång" @@ -7963,11 +7958,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9384,7 @@ "name-unit": "Enhet", "name-value-name": "Värdenamn", "name-y-axis-scale": "Y-axelns skala", + "name-y-bucket-scale": "", "placeholder-axis-label": "Auto", "placeholder-axis-width": "Auto", "placeholder-decimals": "Auto", @@ -9422,6 +9414,18 @@ "label-all": "Alla", "label-hidden": "Dolt", "label-single": "Singel" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11207,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11981,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Visa detaljer", "loading-finished-job": "Läser in färdigt jobb …", @@ -12078,6 +12090,9 @@ "webhook-last-event": "Senaste händelsen:", "webhook-url": "Visa webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Tillbaka till lagringsplatserna", "cleaning-up-resources": "Rensa lagringsplatsresurser", @@ -12182,6 +12197,9 @@ "tooltip-unhealthy-repository": "Det gick inte att hämta en ohälsosam lagringsplats" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12219,15 @@ }, "warning-title-default": "Varning", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "Hur lång den här processen är beror på hur många resurser som är inblandade.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12246,7 @@ "step-finish": "Välj ytterligare inställningar", "step-synchronize": "Synkronisera med extern lagring", "sync-description": "Synkronisera resurser med extern lagring. Efter det här engångssteget sparas alla framtida uppdateringar automatiskt på lagringsplatsen och provisioneras tillbaka till instansen.", + "sync-option-migrate-resources": "", "title-bootstrap": "Välj vad du vill synkronisera", "title-connect": "Anslut till extern lagring", "title-finish": "Välj ytterligare inställningar", @@ -12460,16 +12484,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index 07a0b6673ac..ad957bd271f 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -2666,6 +2666,35 @@ "text-federated": "Şirket dışı", "text-provisioned": "Sağlanan" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Veri kaynağı", @@ -3717,6 +3746,7 @@ "text": "Sorgunuz için sonuç bulunamadı" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6180,7 +6210,7 @@ "title-someone-else-has-updated-this-dashboard": "Başka biri bu panoyu güncelledi", "would-still-dashboard": "Yine de bu panoyu kaydetmek istiyor musunuz?" }, - "save-and-overwrite": "\"Kaydet ve üzerine yaz\"" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7195,9 @@ "time-range-label": "Zaman aralığını kilitle" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Uzman ipucu: {{proTip}}" }, @@ -7641,39 +7674,6 @@ }, "share-span": "Paylaş" }, - "span-filters": { - "aria-label-select-max-span-operator": "Maksimum zaman aralığı işlecini seçin", - "aria-label-select-min-span-operator": "Minimum zaman aralığı işlecini seçin", - "aria-label-select-service-name": "Hizmet adını seç", - "aria-label-select-service-name-operator": "Hizmet adını işlecini seç", - "aria-label-select-span-name": "Zaman aralığı adı seç", - "aria-label-select-span-name-operator": "Zaman aralığı adı işleci seçin", - "ariaLabel-select-max-span-duration": "Maksimum zaman aralığı süresini seçin", - "ariaLabel-select-min-span-duration": "Minimum zaman aralığı süresini seçin", - "label-collapse": "Zaman aralığı filtreleri", - "label-duration": "Süre", - "label-service-name": "Hizmet adı", - "label-span-name": "Zaman aralığı adı", - "label-tags": "Etiketler", - "placeholder-all-service-names": "Tüm hizmet adları", - "placeholder-all-span-names": "Tüm zaman aralığı adları", - "tooltip-collapse": "Aşağıdaki zaman aralıklarınızı filtreleyin. İlginizi en çok çeken birkaç sonucu daraltana kadar filtre uygulamaya devam edebilirsiniz.", - "tooltip-duration": "Süreye göre filtreleyin. Kabul edilen birimler: {{units}}", - "tooltip-tags": "Zamanlardaki etiketlere, işlem etiketlerine veya günlük kaydı alanlarına göre filtreleyin." - }, - "span-filters-tags": { - "aria-label-add-tag": "Etiket ekle", - "aria-label-input-tag-value": "Etiket değeri girin", - "aria-label-remove-tag": "Etiketi kaldır", - "aria-label-select-tag-key": "Etiket anahtarını seçin", - "aria-label-select-tag-operator": "Etiket işlecini seçin", - "aria-label-select-tag-value": "Etiket değerini seçin", - "placeholder-select-tag": "Etiket seçin", - "placeholder-select-value": "Değer seçin", - "placeholder-tag-value": "Etiket değeri", - "tooltip-add-tag": "Etiket ekle", - "tooltip-remove-tag": "Etiketi kaldır" - }, "span-flame-graph": { "flame-graph": "Alev grafiği" }, @@ -7872,23 +7872,18 @@ "label-upsample": "Örneklemeyi artır", "tooltip-s-m-h": "10 sn, 1 dk, 30 dk, 1 sa" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "", - "modal-title": "", "tooltip-experimental": "" }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Girdi" @@ -7963,11 +7958,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9384,7 @@ "name-unit": "Birim", "name-value-name": "Değer adı", "name-y-axis-scale": "Y ekseni ölçeği", + "name-y-bucket-scale": "", "placeholder-axis-label": "Otomatik", "placeholder-axis-width": "Otomatik", "placeholder-decimals": "Otomatik", @@ -9422,6 +9414,18 @@ "label-all": "Tümü", "label-hidden": "Gizli", "label-single": "Tek" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11207,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11981,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Ayrıntıları görüntüleyin", "loading-finished-job": "Tamamlanan iş yükleniyor...", @@ -12078,6 +12090,9 @@ "webhook-last-event": "Son Olay:", "webhook-url": "Web Kancasını Görüntüle" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Depolara geri dön", "cleaning-up-resources": "Depo kaynakları temizleniyor", @@ -12182,6 +12197,9 @@ "tooltip-unhealthy-repository": "İyi durumda olmayan bir depo çekilemedi" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12219,15 @@ }, "warning-title-default": "Uyarı", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "Bu işlemin süresi dâhil olan kaynakların sayısına bağlıdır.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12246,7 @@ "step-finish": "Ek ayarlar seçin", "step-synchronize": "Harici depolama ile senkronize et", "sync-description": "Kaynakları harici depolama ile senkronize edin. Bu tek seferlik adımdan sonra tüm gelecekteki güncellemeler otomatik olarak depoya kaydedilecek ve örneğe geri sağlanacaktır.", + "sync-option-migrate-resources": "", "title-bootstrap": "Nelerin senkronize edileceğini seçin", "title-connect": "Harici depolamaya bağlan", "title-finish": "Ek ayarlar seçin", @@ -12460,16 +12484,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index a1eb5ea1fae..f02bcda5189 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -2655,6 +2655,35 @@ "text-federated": "联合", "text-provisioned": "已预置" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "数据源", @@ -3701,6 +3730,7 @@ "text": "未找到与您的查询相关的结果" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6159,7 +6189,7 @@ "title-someone-else-has-updated-this-dashboard": "其他人已更新此数据面板", "would-still-dashboard": "您仍然要保存此数据面板吗?" }, - "save-and-overwrite": "‘保存并覆盖’" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7142,6 +7172,9 @@ "time-range-label": "锁定时间范围" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "专业提示:{{proTip}}" }, @@ -7616,39 +7649,6 @@ }, "share-span": "共享" }, - "span-filters": { - "aria-label-select-max-span-operator": "选择最大跨度运算符", - "aria-label-select-min-span-operator": "选择最小跨度运算符", - "aria-label-select-service-name": "选择服务名称", - "aria-label-select-service-name-operator": "选择服务名称运算符", - "aria-label-select-span-name": "选择跨度名称", - "aria-label-select-span-name-operator": "选择跨度名称运算符", - "ariaLabel-select-max-span-duration": "选择最大跨度持续时间", - "ariaLabel-select-min-span-duration": "选择最小跨度持续时间", - "label-collapse": "跨度筛选器", - "label-duration": "持续时间", - "label-service-name": "服务名称", - "label-span-name": "跨度名称", - "label-tags": "标签", - "placeholder-all-service-names": "所有服务名称", - "placeholder-all-span-names": "所有跨度名称", - "tooltip-collapse": "在下方筛选您的跨度。您可以继续应用筛选条件,直到将结果跨度缩小到您最感兴趣的少数几个。", - "tooltip-duration": "按持续时间筛选。可接受的单位是 {{units}}", - "tooltip-tags": "根据跨度中的标记、流程标记或日志字段进行筛选。" - }, - "span-filters-tags": { - "aria-label-add-tag": "添加标记", - "aria-label-input-tag-value": "输入标记值", - "aria-label-remove-tag": "移除标记", - "aria-label-select-tag-key": "选择标记键", - "aria-label-select-tag-operator": "选择标记运算符", - "aria-label-select-tag-value": "选择标记值", - "placeholder-select-tag": "选择标记", - "placeholder-select-value": "选择值", - "placeholder-tag-value": "标记值", - "tooltip-add-tag": "添加标记", - "tooltip-remove-tag": "移除标记" - }, "span-flame-graph": { "flame-graph": "火焰图" }, @@ -7847,23 +7847,18 @@ "label-upsample": "升高采样", "tooltip-s-m-h": "10 秒、1 分钟、30 分钟、1 小时" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "运行查询", - "modal-title": "SQL 编辑器", "tooltip-experimental": "SQL 表达式 LLM 集成是实验性的。若有任何问题,请向 Grafana 团队报告。" }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "输入" @@ -7938,11 +7933,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9358,6 +9349,7 @@ "name-unit": "单位", "name-value-name": "值名称", "name-y-axis-scale": "Y 轴比例", + "name-y-bucket-scale": "", "placeholder-axis-label": "自动", "placeholder-axis-width": "自动", "placeholder-decimals": "自动", @@ -9387,6 +9379,18 @@ "label-all": "全部", "label-hidden": "隐藏", "label-single": "单一" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11157,9 +11161,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11921,7 +11930,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "查看详情", "loading-finished-job": "正在加载已完成的作业...", @@ -12027,6 +12039,9 @@ "webhook-last-event": "最后一个事件:", "webhook-url": "查看 Webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "回到存储库", "cleaning-up-resources": "清理存储库资源", @@ -12131,6 +12146,9 @@ "tooltip-unhealthy-repository": "无法拉取状态不良的存储库" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12150,10 +12168,15 @@ }, "warning-title-default": "警告", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "此过程的持续时间取决于所涉及资源的数量。", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12172,6 +12195,7 @@ "step-finish": "选择附加设置", "step-synchronize": "与外部存储同步", "sync-description": "将资源与外部存储同步。完成此一次性步骤后,所有后续更新都将自动保存到存储库中,并预配回实例。", + "sync-option-migrate-resources": "", "title-bootstrap": "选择要同步的内容", "title-connect": "连接到外部存储", "title-finish": "选择附加设置", @@ -12408,16 +12432,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index df0a64f1c1e..dc05987a2e6 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -2655,6 +2655,35 @@ "text-federated": "聯合", "text-provisioned": "已佈建" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "資料來源", @@ -3701,6 +3730,7 @@ "text": "未找到您的查詢結果" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6159,7 +6189,7 @@ "title-someone-else-has-updated-this-dashboard": "其他人已更新此儀表板", "would-still-dashboard": "仍要儲存此儀表板嗎?" }, - "save-and-overwrite": "「儲存並覆寫」" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7142,6 +7172,9 @@ "time-range-label": "鎖定時間範圍" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "專業提示:{{proTip}}" }, @@ -7616,39 +7649,6 @@ }, "share-span": "分享" }, - "span-filters": { - "aria-label-select-max-span-operator": "選擇最大範圍的運算子", - "aria-label-select-min-span-operator": "選取最小範圍的運算子", - "aria-label-select-service-name": "選取服務名稱", - "aria-label-select-service-name-operator": "選取服務名稱運算子", - "aria-label-select-span-name": "選取範圍名稱", - "aria-label-select-span-name-operator": "選擇範圍名稱運算子", - "ariaLabel-select-max-span-duration": "選擇最大跨度持續時間", - "ariaLabel-select-min-span-duration": "選取最小跨度持續時間", - "label-collapse": "範圍篩選器", - "label-duration": "持續時間", - "label-service-name": "服務名稱", - "label-span-name": "範圍名稱", - "label-tags": "標籤", - "placeholder-all-service-names": "所有服務名稱", - "placeholder-all-span-names": "所有範圍名稱", - "tooltip-collapse": "篩選下方的範圍。您可以繼續套用篩選條件,直到將結果範圍縮小到您最感興趣的幾個範圍。", - "tooltip-duration": "依持續時間篩選。可接受的單元為 {{units}}", - "tooltip-tags": "按範圍中的標記、流程標記或紀錄欄位篩選。" - }, - "span-filters-tags": { - "aria-label-add-tag": "新增標記", - "aria-label-input-tag-value": "輸入標記值", - "aria-label-remove-tag": "移除標記", - "aria-label-select-tag-key": "選擇標記鍵", - "aria-label-select-tag-operator": "選擇標記運算子", - "aria-label-select-tag-value": "選擇標記值", - "placeholder-select-tag": "選擇標記", - "placeholder-select-value": "選擇值", - "placeholder-tag-value": "標記值", - "tooltip-add-tag": "新增標記", - "tooltip-remove-tag": "移除標記" - }, "span-flame-graph": { "flame-graph": "火焰圖" }, @@ -7847,23 +7847,18 @@ "label-upsample": "向上取樣", "tooltip-s-m-h": "10 秒、1 分鐘、30 分鐘、1 小時" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "執行查詢", - "modal-title": "SQL 編輯器", "tooltip-experimental": "SQL 運算式 LLM 整合為實驗性功能。如有任何問題,請向 Grafana 團隊回報。" }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "輸入" @@ -7938,11 +7933,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9358,6 +9349,7 @@ "name-unit": "單位", "name-value-name": "值名稱", "name-y-axis-scale": "Y 軸刻度", + "name-y-bucket-scale": "", "placeholder-axis-label": "自動", "placeholder-axis-width": "自動", "placeholder-decimals": "自動", @@ -9387,6 +9379,18 @@ "label-all": "全部", "label-hidden": "隱藏", "label-single": "單一" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11157,9 +11161,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11921,7 +11930,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "檢視詳細資料", "loading-finished-job": "正在載入已完成的作業…", @@ -12027,6 +12039,9 @@ "webhook-last-event": "上次事件:", "webhook-url": "檢視 Webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "返回至儲存庫", "cleaning-up-resources": "清理儲存庫資源", @@ -12131,6 +12146,9 @@ "tooltip-unhealthy-repository": "無法拉取狀態不佳的儲存庫" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12150,10 +12168,15 @@ }, "warning-title-default": "警告", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "此流程的持續時間取決於所涉及的資源數量。", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12172,6 +12195,7 @@ "step-finish": "選擇附加設定", "step-synchronize": "與外部儲存空間同步", "sync-description": "將資源與外部儲存空間同步。在此一次性步驟之後,未來的所有更新都將自動儲存到儲存庫中,並佈建回執行個體。", + "sync-option-migrate-resources": "", "title-bootstrap": "選擇要同步的內容", "title-connect": "連接到外部儲存空間", "title-finish": "選擇附加設定", @@ -12408,16 +12432,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", From 15b5dcda806bbea6e01d7fdc056c3af5facd0eb9 Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Mon, 22 Dec 2025 04:00:37 -0700 Subject: [PATCH 071/229] Dashboard V1->V2 Conversion: Default multi to true in GroupBy when multi is not defined in v1 (#115656) default multi to true when multi is not defined in v1 --- .../testdata/input/v1beta1.groupby.json | 166 +++++++++++++ .../output/v1beta1.groupby.v0alpha1.json | 172 +++++++++++++ .../output/v1beta1.groupby.v2alpha1.json | 229 +++++++++++++++++ .../output/v1beta1.groupby.v2beta1.json | 232 ++++++++++++++++++ .../conversion/v1beta1_to_v2alpha1.go | 4 +- 5 files changed, 802 insertions(+), 1 deletion(-) create mode 100644 apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.groupby.json create mode 100644 apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v0alpha1.json create mode 100644 apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v2alpha1.json create mode 100644 apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v2beta1.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.groupby.json b/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.groupby.json new file mode 100644 index 00000000000..88527ef6036 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.groupby.json @@ -0,0 +1,166 @@ +{ + "kind": "DashboardWithAccessInfo", + "apiVersion": "dashboard.grafana.app/v1beta1", + "metadata": { + "name": "groupby-test" + }, + "spec": { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "test-uid" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0-pre", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "test-uid" + }, + "editorMode": "code", + "expr": "sum(counters_requests)", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "works with group by var", + "type": "timeseries" + } + ], + "preload": false, + "schemaVersion": 42, + "tags": [], + "templating": { + "list": [ + { + "current": { + "text": [ + "a_legacy_label", + "app", + "exported_instance", + "exported_job" + ], + "value": [ + "a_legacy_label", + "app", + "exported_instance", + "exported_job" + ] + }, + "datasource": { + "type": "prometheus", + "uid": "test-uid" + }, + "name": "Group by", + "type": "groupby" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "groupby test", + "weekStart": "" + } + } \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v0alpha1.json new file mode 100644 index 00000000000..463d1864dce --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v0alpha1.json @@ -0,0 +1,172 @@ +{ + "kind": "DashboardWithAccessInfo", + "apiVersion": "dashboard.grafana.app/v0alpha1", + "metadata": { + "name": "groupby-test" + }, + "spec": { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "test-uid" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0-pre", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "test-uid" + }, + "editorMode": "code", + "expr": "sum(counters_requests)", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "works with group by var", + "type": "timeseries" + } + ], + "preload": false, + "schemaVersion": 42, + "tags": [], + "templating": { + "list": [ + { + "current": { + "text": [ + "a_legacy_label", + "app", + "exported_instance", + "exported_job" + ], + "value": [ + "a_legacy_label", + "app", + "exported_instance", + "exported_job" + ] + }, + "datasource": { + "type": "prometheus", + "uid": "test-uid" + }, + "name": "Group by", + "type": "groupby" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "groupby test", + "weekStart": "" + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v2alpha1.json new file mode 100644 index 00000000000..58bf555354c --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v2alpha1.json @@ -0,0 +1,229 @@ +{ + "kind": "DashboardWithAccessInfo", + "apiVersion": "dashboard.grafana.app/v2alpha1", + "metadata": { + "name": "groupby-test" + }, + "spec": { + "annotations": [ + { + "kind": "AnnotationQuery", + "spec": { + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "query": { + "kind": "grafana", + "spec": {} + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "builtIn": true, + "legacyOptions": { + "type": "dashboard" + } + } + } + ], + "cursorSync": "Off", + "editable": true, + "elements": { + "panel-2": { + "kind": "Panel", + "spec": { + "id": 2, + "title": "works with group by var", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "prometheus", + "spec": { + "editorMode": "code", + "expr": "sum(counters_requests)", + "legendFormat": "__auto", + "range": true + } + }, + "datasource": { + "type": "prometheus", + "uid": "test-uid" + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "timeseries", + "spec": { + "pluginVersion": "12.4.0-pre", + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + } + } + } + } + } + }, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "x": 12, + "y": 0, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-2" + } + } + } + ] + } + }, + "links": [], + "liveNow": false, + "preload": false, + "tags": [], + "timeSettings": { + "timezone": "browser", + "from": "now-6h", + "to": "now", + "autoRefresh": "", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "hideTimepicker": false, + "fiscalYearStartMonth": 0 + }, + "title": "groupby test", + "variables": [ + { + "kind": "GroupByVariable", + "spec": { + "name": "Group by", + "datasource": { + "type": "prometheus", + "uid": "test-uid" + }, + "current": { + "text": [ + "a_legacy_label", + "app", + "exported_instance", + "exported_job" + ], + "value": [ + "a_legacy_label", + "app", + "exported_instance", + "exported_job" + ] + }, + "options": [], + "multi": true, + "hide": "dontHide", + "skipUrlSync": false + } + } + ] + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v2beta1.json new file mode 100644 index 00000000000..c2ac16a874c --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v2beta1.json @@ -0,0 +1,232 @@ +{ + "kind": "DashboardWithAccessInfo", + "apiVersion": "dashboard.grafana.app/v2beta1", + "metadata": { + "name": "groupby-test" + }, + "spec": { + "annotations": [ + { + "kind": "AnnotationQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "grafana", + "version": "v0", + "datasource": { + "name": "-- Grafana --" + }, + "spec": {} + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "builtIn": true, + "legacyOptions": { + "type": "dashboard" + } + } + } + ], + "cursorSync": "Off", + "editable": true, + "elements": { + "panel-2": { + "kind": "Panel", + "spec": { + "id": 2, + "title": "works with group by var", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "test-uid" + }, + "spec": { + "editorMode": "code", + "expr": "sum(counters_requests)", + "legendFormat": "__auto", + "range": true + } + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "timeseries", + "version": "12.4.0-pre", + "spec": { + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + } + } + } + } + } + }, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "x": 12, + "y": 0, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-2" + } + } + } + ] + } + }, + "links": [], + "liveNow": false, + "preload": false, + "tags": [], + "timeSettings": { + "timezone": "browser", + "from": "now-6h", + "to": "now", + "autoRefresh": "", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "hideTimepicker": false, + "fiscalYearStartMonth": 0 + }, + "title": "groupby test", + "variables": [ + { + "kind": "GroupByVariable", + "group": "prometheus", + "datasource": { + "name": "test-uid" + }, + "spec": { + "name": "Group by", + "current": { + "text": [ + "a_legacy_label", + "app", + "exported_instance", + "exported_job" + ], + "value": [ + "a_legacy_label", + "app", + "exported_instance", + "exported_job" + ] + }, + "options": [], + "multi": true, + "hide": "dontHide", + "skipUrlSync": false + } + } + ] + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go index 92c5d937fe7..224f222ae33 100644 --- a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go +++ b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go @@ -1734,7 +1734,9 @@ func buildGroupByVariable(ctx context.Context, varMap map[string]interface{}, co Hide: commonProps.Hide, SkipUrlSync: commonProps.SkipUrlSync, Current: buildVariableCurrent(varMap["current"]), - Multi: getBoolField(varMap, "multi", false), + // We set it to true by default because GroupByVariable + // constructor defaults to multi: true + Multi: getBoolField(varMap, "multi", true), }, } From dd1edf7f16a6d9ff132002eea175a5c1765777e3 Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Mon, 22 Dec 2025 21:23:59 +0100 Subject: [PATCH 072/229] Alerting: Fix database-based filtering by labels when rules have no labels (#115657) Alerting: Fix database-based filtering by labels when rules have no labels at all --- pkg/services/ngalert/store/alert_rule_labels_test.go | 12 ++++++------ pkg/services/ngalert/store/alert_rule_test.go | 12 +++++++++--- pkg/services/ngalert/store/json.go | 12 ++++++------ pkg/services/ngalert/store/json_test.go | 12 ++++++------ 4 files changed, 27 insertions(+), 21 deletions(-) diff --git a/pkg/services/ngalert/store/alert_rule_labels_test.go b/pkg/services/ngalert/store/alert_rule_labels_test.go index 9b72d8f00f9..2006c49fd7a 100644 --- a/pkg/services/ngalert/store/alert_rule_labels_test.go +++ b/pkg/services/ngalert/store/alert_rule_labels_test.go @@ -73,42 +73,42 @@ func TestBuildLabelMatcherJSON(t *testing.T) { name: "MySQL MatchEqual with non-empty value", dialect: migrator.NewMysqlDialect(), matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: "alerting"}, - wantSQL: "JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) = ?", + wantSQL: "JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) = ?", wantArgs: []any{"team", "alerting"}, }, { name: "MySQL MatchEqual with empty value", dialect: migrator.NewMysqlDialect(), matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: ""}, - wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) = ? OR JSON_EXTRACT(labels, CONCAT('$.', ?)) IS NULL)", + wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) = ? OR JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?)) IS NULL)", wantArgs: []any{"team", "", "team"}, }, { name: "MySQL MatchNotEqual", dialect: migrator.NewMysqlDialect(), matcher: &labels.Matcher{Type: labels.MatchNotEqual, Name: "team", Value: "alerting"}, - wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) != ?)", + wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) != ?)", wantArgs: []any{"team", "team", "alerting"}, }, { name: "PostgreSQL MatchEqual with non-empty value", dialect: migrator.NewPostgresDialect(), matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: "alerting"}, - wantSQL: "jsonb_extract_path_text(labels::jsonb, ?) = ?", + wantSQL: "jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) = ?", wantArgs: []any{"team", "alerting"}, }, { name: "PostgreSQL MatchEqual with empty value", dialect: migrator.NewPostgresDialect(), matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: ""}, - wantSQL: "(jsonb_extract_path_text(labels::jsonb, ?) = ? OR jsonb_extract_path_text(labels::jsonb, ?) IS NULL)", + wantSQL: "(jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) = ? OR jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) IS NULL)", wantArgs: []any{"team", "", "team"}, }, { name: "PostgreSQL MatchNotEqual", dialect: migrator.NewPostgresDialect(), matcher: &labels.Matcher{Type: labels.MatchNotEqual, Name: "team", Value: "alerting"}, - wantSQL: "(jsonb_extract_path_text(labels::jsonb, ?) IS NULL OR jsonb_extract_path_text(labels::jsonb, ?) != ?)", + wantSQL: "(jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) IS NULL OR jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) != ?)", wantArgs: []any{"team", "team", "alerting"}, }, { diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index 29b82f943ba..f7497c7b05a 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -2462,6 +2462,12 @@ func TestIntegration_ListAlertRules(t *testing.T) { ruleNonempty := createRule(t, store, ruleGen.With( ruleGen.WithLabels(map[string]string{"empty": "nonempty"}), ruleGen.WithTitle("rule_nonempty"))) + // include a rule with no labels at all, + // to ensure we handle that case correctly. + // JSON functions need to be able to handle null and empty string values. + ruleNoLabels := createRule(t, store, ruleGen.With( + ruleGen.WithLabels(map[string]string{}), + ruleGen.WithTitle("rule_no_labels"))) tc := []struct { name string @@ -2487,7 +2493,7 @@ func TestIntegration_ListAlertRules(t *testing.T) { labelMatchers: labels.Matchers{ func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchNotEqual, "team", "alerting"); return m }(), }, - expectedRules: []*models.AlertRule{ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty, ruleNonempty}, + expectedRules: []*models.AlertRule{ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty, ruleNonempty, ruleNoLabels}, }, { name: "special characters in labels are handled correctly", @@ -2536,7 +2542,7 @@ func TestIntegration_ListAlertRules(t *testing.T) { labelMatchers: labels.Matchers{ func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "empty", ""); return m }(), }, - expectedRules: []*models.AlertRule{ruleLower, ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty}, + expectedRules: []*models.AlertRule{ruleLower, ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty, ruleNoLabels}, }, { name: "inequality matcher on non-existent label matches all rules", @@ -2546,7 +2552,7 @@ func TestIntegration_ListAlertRules(t *testing.T) { return m }(), }, - expectedRules: []*models.AlertRule{ruleLower, ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty, ruleNonempty}, + expectedRules: []*models.AlertRule{ruleLower, ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty, ruleNonempty, ruleNoLabels}, }, } diff --git a/pkg/services/ngalert/store/json.go b/pkg/services/ngalert/store/json.go index 7b634246951..e0c71975a76 100644 --- a/pkg/services/ngalert/store/json.go +++ b/pkg/services/ngalert/store/json.go @@ -13,9 +13,9 @@ import ( func jsonEquals(dialect migrator.Dialect, column, key, value string) (string, []any) { switch dialect.DriverName() { case migrator.MySQL: - return fmt.Sprintf("JSON_UNQUOTE(JSON_EXTRACT(%s, CONCAT('$.', ?))) = ?", column), []any{key, value} + return fmt.Sprintf("JSON_UNQUOTE(JSON_EXTRACT(NULLIF(%s, ''), CONCAT('$.', ?))) = ?", column), []any{key, value} case migrator.Postgres: - return fmt.Sprintf("jsonb_extract_path_text(%s::jsonb, ?) = ?", column), []any{key, value} + return fmt.Sprintf("jsonb_extract_path_text(NULLIF(%s, '')::jsonb, ?) = ?", column), []any{key, value} default: return "", nil } @@ -25,9 +25,9 @@ func jsonNotEquals(dialect migrator.Dialect, column, key, value string) (string, var jx string switch dialect.DriverName() { case migrator.MySQL: - jx = fmt.Sprintf("JSON_UNQUOTE(JSON_EXTRACT(%s, CONCAT('$.', ?)))", column) + jx = fmt.Sprintf("JSON_UNQUOTE(JSON_EXTRACT(NULLIF(%s, ''), CONCAT('$.', ?)))", column) case migrator.Postgres: - jx = fmt.Sprintf("jsonb_extract_path_text(%s::jsonb, ?)", column) + jx = fmt.Sprintf("jsonb_extract_path_text(NULLIF(%s, '')::jsonb, ?)", column) default: return "", nil } @@ -37,9 +37,9 @@ func jsonNotEquals(dialect migrator.Dialect, column, key, value string) (string, func jsonKeyMissing(dialect migrator.Dialect, column, key string) (string, []any) { switch dialect.DriverName() { case migrator.MySQL: - return fmt.Sprintf("JSON_EXTRACT(%s, CONCAT('$.', ?)) IS NULL", column), []any{key} + return fmt.Sprintf("JSON_EXTRACT(NULLIF(%s, ''), CONCAT('$.', ?)) IS NULL", column), []any{key} case migrator.Postgres: - return fmt.Sprintf("jsonb_extract_path_text(%s::jsonb, ?) IS NULL", column), []any{key} + return fmt.Sprintf("jsonb_extract_path_text(NULLIF(%s, '')::jsonb, ?) IS NULL", column), []any{key} default: return "", nil } diff --git a/pkg/services/ngalert/store/json_test.go b/pkg/services/ngalert/store/json_test.go index 89f85a027a6..d09d3741c4b 100644 --- a/pkg/services/ngalert/store/json_test.go +++ b/pkg/services/ngalert/store/json_test.go @@ -23,7 +23,7 @@ func TestJsonEquals(t *testing.T) { column: "labels", key: "team", value: "alerting", - wantSQL: "JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) = ?", + wantSQL: "JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) = ?", wantArgs: []any{"team", "alerting"}, }, { @@ -32,7 +32,7 @@ func TestJsonEquals(t *testing.T) { column: "labels", key: "team", value: "alerting", - wantSQL: "jsonb_extract_path_text(labels::jsonb, ?) = ?", + wantSQL: "jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) = ?", wantArgs: []any{"team", "alerting"}, }, } @@ -62,7 +62,7 @@ func TestJsonNotEquals(t *testing.T) { column: "labels", key: "team", value: "alerting", - wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) != ?)", + wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) != ?)", wantArgs: []any{"team", "team", "alerting"}, }, { @@ -71,7 +71,7 @@ func TestJsonNotEquals(t *testing.T) { column: "labels", key: "team", value: "alerting", - wantSQL: "(jsonb_extract_path_text(labels::jsonb, ?) IS NULL OR jsonb_extract_path_text(labels::jsonb, ?) != ?)", + wantSQL: "(jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) IS NULL OR jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) != ?)", wantArgs: []any{"team", "team", "alerting"}, }, } @@ -99,7 +99,7 @@ func TestJsonKeyMissing(t *testing.T) { dialect: migrator.NewMysqlDialect(), column: "labels", key: "team", - wantSQL: "JSON_EXTRACT(labels, CONCAT('$.', ?)) IS NULL", + wantSQL: "JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?)) IS NULL", wantArgs: []any{"team"}, }, { @@ -107,7 +107,7 @@ func TestJsonKeyMissing(t *testing.T) { dialect: migrator.NewPostgresDialect(), column: "labels", key: "team", - wantSQL: "jsonb_extract_path_text(labels::jsonb, ?) IS NULL", + wantSQL: "jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) IS NULL", wantArgs: []any{"team"}, }, } From 096208202ebab02f8842ae08803b1df724dcedfc Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Tue, 23 Dec 2025 11:23:16 +0100 Subject: [PATCH 073/229] Alerting: Fix a race condition panic in ResetStateByRuleUID (#115662) --- pkg/services/ngalert/schedule/registry.go | 16 ++++++----- pkg/services/ngalert/state/manager.go | 10 ++++--- pkg/services/ngalert/state/manager_test.go | 33 ++++++++++++++++++++++ 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/pkg/services/ngalert/schedule/registry.go b/pkg/services/ngalert/schedule/registry.go index 892e0af8235..6ce09d49169 100644 --- a/pkg/services/ngalert/schedule/registry.go +++ b/pkg/services/ngalert/schedule/registry.go @@ -101,13 +101,13 @@ func (e *Evaluation) Fingerprint() fingerprint { type alertRulesRegistry struct { rules map[models.AlertRuleKey]*models.AlertRule folderTitles map[models.FolderKey]string - mu sync.Mutex + mu sync.RWMutex } // all returns all rules in the registry. func (r *alertRulesRegistry) all() ([]*models.AlertRule, map[models.FolderKey]string) { - r.mu.Lock() - defer r.mu.Unlock() + r.mu.RLock() + defer r.mu.RUnlock() result := make([]*models.AlertRule, 0, len(r.rules)) for _, rule := range r.rules { result = append(result, rule) @@ -116,8 +116,8 @@ func (r *alertRulesRegistry) all() ([]*models.AlertRule, map[models.FolderKey]st } func (r *alertRulesRegistry) get(k models.AlertRuleKey) *models.AlertRule { - r.mu.Lock() - defer r.mu.Unlock() + r.mu.RLock() + defer r.mu.RUnlock() return r.rules[k] } @@ -157,12 +157,14 @@ func (r *alertRulesRegistry) del(k models.AlertRuleKey) (*models.AlertRule, bool } func (r *alertRulesRegistry) isEmpty() bool { - r.mu.Lock() - defer r.mu.Unlock() + r.mu.RLock() + defer r.mu.RUnlock() return len(r.rules) == 0 } func (r *alertRulesRegistry) needsUpdate(keys []models.AlertRuleKeyWithVersion) bool { + r.mu.RLock() + defer r.mu.RUnlock() if len(r.rules) != len(keys) { return true } diff --git a/pkg/services/ngalert/state/manager.go b/pkg/services/ngalert/state/manager.go index 4d6e066f2bf..45c29f9d684 100644 --- a/pkg/services/ngalert/state/manager.go +++ b/pkg/services/ngalert/state/manager.go @@ -232,9 +232,7 @@ func (st *Manager) Get(orgID int64, alertRuleUID string, stateId data.Fingerprin return st.cache.get(orgID, alertRuleUID, stateId) } -// DeleteStateByRuleUID removes the rule instances from cache and instanceStore. A closed channel is returned to be able -// to gracefully handle the clear state step in scheduler in case we do not need to use the historian to save state -// history. +// DeleteStateByRuleUID removes the rule instances from cache and instanceStore. func (st *Manager) DeleteStateByRuleUID(ctx context.Context, ruleKey ngModels.AlertRuleKeyWithGroup, reason string) []StateTransition { logger := st.log.FromContext(ctx) logger.Debug("Resetting state of the rule") @@ -292,10 +290,14 @@ func (st *Manager) ForgetStateByRuleUID(ctx context.Context, ruleKey ngModels.Al // ResetStateByRuleUID removes the rule instances from cache and instanceStore and saves state history. If the state // history has to be saved, rule must not be nil. func (st *Manager) ResetStateByRuleUID(ctx context.Context, rule *ngModels.AlertRule, reason string) []StateTransition { + if rule == nil { + return nil + } + ruleKey := rule.GetKeyWithGroup() transitions := st.DeleteStateByRuleUID(ctx, ruleKey, reason) - if rule == nil || st.historian == nil || len(transitions) == 0 { + if st.historian == nil || len(transitions) == 0 { return transitions } diff --git a/pkg/services/ngalert/state/manager_test.go b/pkg/services/ngalert/state/manager_test.go index 9dccbe52d2d..b8b366ac619 100644 --- a/pkg/services/ngalert/state/manager_test.go +++ b/pkg/services/ngalert/state/manager_test.go @@ -2051,6 +2051,39 @@ func TestIntegrationResetStateByRuleUID(t *testing.T) { } } +func TestResetStateByRuleUID(t *testing.T) { + ctx := context.Background() + + setupManager := func(historian state.Historian) *state.Manager { + cfg := state.ManagerCfg{ + Metrics: metrics.NewNGAlert(prometheus.NewPedanticRegistry()).GetStateMetrics(), + ExternalURL: nil, + InstanceStore: &state.FakeInstanceStore{}, + Images: &state.NoopImageService{}, + Clock: clock.NewMock(), + Historian: historian, + Tracer: tracing.InitializeTracerForTest(), + Log: log.New("ngalert.state.manager"), + } + + return state.NewManager(cfg, state.NewNoopPersister()) + } + + t.Run("with nil historian", func(t *testing.T) { + manager := setupManager(nil) + + transitions := manager.ResetStateByRuleUID(ctx, nil, "test reason") + require.Empty(t, transitions) + }) + + t.Run("with historian", func(t *testing.T) { + manager := setupManager(&state.FakeHistorian{}) + + transitions := manager.ResetStateByRuleUID(ctx, nil, "test reason") + require.Empty(t, transitions) + }) +} + func setCacheID(s *state.State) *state.State { if s.CacheID != 0 { return s From 84120fb2107267701ba3f4f22ccb24fac943e9c9 Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Tue, 23 Dec 2025 11:26:15 +0100 Subject: [PATCH 074/229] Alerting: Fix file import/export of recording rules with target datasource uid (#115663) Alerting: Fix export of recording rules with target datasource uid --- .../test-data/post-rulegroup-101-export.hcl | 45 ++++++++++ .../test-data/post-rulegroup-101-export.json | 61 ++++++++++++++ .../test-data/post-rulegroup-101-export.yaml | 45 ++++++++++ .../api/test-data/post-rulegroup-101.json | 65 +++++++++++++++ .../alerting/config_reader_test.go | 40 +++++++++ .../provisioning/alerting/rules_types.go | 10 ++- .../provisioning/alerting/rules_types_test.go | 82 ++++++------------- .../with-target-datasource.yml | 26 ++++++ .../without-target-datasource.yml | 25 ++++++ .../test-data/rulegroup-1-export.json | 27 ++++++ .../alerting/test-data/rulegroup-1-get.json | 38 +++++++++ .../alerting/test-data/rulegroup-1-post.json | 20 +++++ pkg/tests/api/alerting/testing.go | 1 + 13 files changed, 426 insertions(+), 59 deletions(-) create mode 100644 pkg/services/provisioning/alerting/testdata/alert_rules/recording-rules/with-target-datasource.yml create mode 100644 pkg/services/provisioning/alerting/testdata/alert_rules/recording-rules/without-target-datasource.yml diff --git a/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.hcl b/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.hcl index 3a5882e0a49..5daa4dce406 100644 --- a/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.hcl +++ b/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.hcl @@ -173,4 +173,49 @@ resource "grafana_rule_group" "rule_group_d3e8424bfbf66bc3" { from = "condition" } } + rule { + name = "recording rule with target" + + data { + ref_id = "query" + + relative_time_range { + from = 18000 + to = 10800 + } + + datasource_uid = "000000002" + model = "{\"expr\":\"rate(http_requests_total[5m])\",\"hide\":false,\"interval\":\"\",\"intervalMs\":1000,\"legendFormat\":\"\",\"maxDataPoints\":100,\"refId\":\"query\"}" + } + data { + ref_id = "reduced" + + relative_time_range { + from = 18000 + to = 10800 + } + + datasource_uid = "__expr__" + model = "{\"expression\":\"query\",\"hide\":false,\"intervalMs\":1000,\"maxDataPoints\":100,\"reducer\":\"mean\",\"refId\":\"reduced\",\"type\":\"reduce\"}" + } + data { + ref_id = "condition" + + relative_time_range { + from = 18000 + to = 10800 + } + + datasource_uid = "__expr__" + model = "{\"expression\":\"$reduced > 5\",\"hide\":false,\"intervalMs\":1000,\"maxDataPoints\":100,\"refId\":\"condition\",\"type\":\"math\"}" + } + + is_paused = false + + record { + metric = "http_requests_rate" + from = "condition" + target_datasource_uid = "000000003" + } + } } diff --git a/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.json b/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.json index bde4bb31f57..4fe9a2dc75a 100644 --- a/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.json +++ b/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.json @@ -233,6 +233,67 @@ "metric": "test_metric", "from": "condition" } + }, + { + "title": "recording rule with target", + "data": [ + { + "refId": "query", + "relativeTimeRange": { + "from": 18000, + "to": 10800 + }, + "datasourceUid": "000000002", + "model": { + "expr": "rate(http_requests_total[5m])", + "hide": false, + "interval": "", + "intervalMs": 1000, + "legendFormat": "", + "maxDataPoints": 100, + "refId": "query" + } + }, + { + "refId": "reduced", + "relativeTimeRange": { + "from": 18000, + "to": 10800 + }, + "datasourceUid": "__expr__", + "model": { + "expression": "query", + "hide": false, + "intervalMs": 1000, + "maxDataPoints": 100, + "reducer": "mean", + "refId": "reduced", + "type": "reduce" + } + }, + { + "refId": "condition", + "relativeTimeRange": { + "from": 18000, + "to": 10800 + }, + "datasourceUid": "__expr__", + "model": { + "expression": "$reduced \u003e 5", + "hide": false, + "intervalMs": 1000, + "maxDataPoints": 100, + "refId": "condition", + "type": "math" + } + } + ], + "isPaused": false, + "record": { + "metric": "http_requests_rate", + "from": "condition", + "targetDatasourceUid": "000000003" + } } ] } diff --git a/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.yaml b/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.yaml index bdb6e9e8362..242a2204823 100644 --- a/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.yaml +++ b/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.yaml @@ -187,3 +187,48 @@ groups: record: metric: test_metric from: condition + - title: recording rule with target + data: + - refId: query + relativeTimeRange: + from: 18000 + to: 10800 + datasourceUid: "000000002" + model: + expr: rate(http_requests_total[5m]) + hide: false + interval: "" + intervalMs: 1000 + legendFormat: "" + maxDataPoints: 100 + refId: query + - refId: reduced + relativeTimeRange: + from: 18000 + to: 10800 + datasourceUid: __expr__ + model: + expression: query + hide: false + intervalMs: 1000 + maxDataPoints: 100 + reducer: mean + refId: reduced + type: reduce + - refId: condition + relativeTimeRange: + from: 18000 + to: 10800 + datasourceUid: __expr__ + model: + expression: $reduced > 5 + hide: false + intervalMs: 1000 + maxDataPoints: 100 + refId: condition + type: math + isPaused: false + record: + metric: http_requests_rate + from: condition + targetDatasourceUid: "000000003" diff --git a/pkg/services/ngalert/api/test-data/post-rulegroup-101.json b/pkg/services/ngalert/api/test-data/post-rulegroup-101.json index f0eaa36d40d..e9936041979 100644 --- a/pkg/services/ngalert/api/test-data/post-rulegroup-101.json +++ b/pkg/services/ngalert/api/test-data/post-rulegroup-101.json @@ -240,6 +240,71 @@ "from": "condition" } } + }, + { + "grafana_alert": { + "title": "recording rule with target", + "data": [ + { + "refId": "query", + "queryType": "", + "relativeTimeRange": { + "from": 18000, + "to": 10800 + }, + "datasourceUid": "000000002", + "model": { + "expr": "rate(http_requests_total[5m])", + "hide": false, + "interval": "", + "intervalMs": 1000, + "legendFormat": "", + "maxDataPoints": 100, + "refId": "query" + } + }, + { + "refId": "reduced", + "queryType": "", + "relativeTimeRange": { + "from": 18000, + "to": 10800 + }, + "datasourceUid": "__expr__", + "model": { + "expression": "query", + "hide": false, + "intervalMs": 1000, + "maxDataPoints": 100, + "reducer": "mean", + "refId": "reduced", + "type": "reduce" + } + }, + { + "refId": "condition", + "queryType": "", + "relativeTimeRange": { + "from": 18000, + "to": 10800 + }, + "datasourceUid": "__expr__", + "model": { + "expression": "$reduced > 5", + "hide": false, + "intervalMs": 1000, + "maxDataPoints": 100, + "refId": "condition", + "type": "math" + } + } + ], + "record": { + "metric": "http_requests_rate", + "from": "condition", + "target_datasource_uid": "000000003" + } + } } ] } diff --git a/pkg/services/provisioning/alerting/config_reader_test.go b/pkg/services/provisioning/alerting/config_reader_test.go index c13780633c3..9cb71b4950f 100644 --- a/pkg/services/provisioning/alerting/config_reader_test.go +++ b/pkg/services/provisioning/alerting/config_reader_test.go @@ -19,6 +19,7 @@ const ( testFileDasboardTypoSupport = "./testdata/alert_rules/dasboard-typo-support" testFileMultipleRules = "./testdata/alert_rules/multiple-rules" testFileMultipleFiles = "./testdata/alert_rules/multiple-files" + testFileRecordingRules = "./testdata/alert_rules/recording-rules" testFileCorrectProperties_cp = "./testdata/contact_points/correct-properties" testFileCorrectPropertiesWithOrg_cp = "./testdata/contact_points/correct-properties-with-org" testFileEmptyUID = "./testdata/contact_points/empty-uid" @@ -188,4 +189,43 @@ func TestConfigReader(t *testing.T) { } }) }) + + t.Run("recording rules should parse correctly", func(t *testing.T) { + ruleFiles, err := configReader.readConfig(ctx, testFileRecordingRules) + require.NoError(t, err) + require.Len(t, ruleFiles, 2) + + findRule := func(title string) *AlertingFile { + for _, rf := range ruleFiles { + if rf.Groups[0].Title == title { + return rf + } + } + return nil + } + + ruleWithTarget := findRule("recording_rules_group") + require.NotNil(t, ruleWithTarget) + + require.Len(t, ruleWithTarget.Groups, 1) + require.Len(t, ruleWithTarget.Groups[0].Rules, 1) + + ruleWith := ruleWithTarget.Groups[0].Rules[0] + require.NotNil(t, ruleWith.Record) + require.Equal(t, "my_recorded_metric", ruleWith.Record.Metric) + require.Equal(t, "A", ruleWith.Record.From) + require.Equal(t, "mimir-uid", ruleWith.Record.TargetDatasourceUID) + + ruleWithoutTarget := findRule("recording_rules_group_no_target") + require.NotNil(t, ruleWithoutTarget) + + require.Len(t, ruleWithoutTarget.Groups, 1) + require.Len(t, ruleWithoutTarget.Groups[0].Rules, 1) + + ruleWithout := ruleWithoutTarget.Groups[0].Rules[0] + require.NotNil(t, ruleWithout.Record) + require.Equal(t, "http_requests_rate", ruleWithout.Record.Metric) + require.Equal(t, "A", ruleWithout.Record.From) + require.Equal(t, "", ruleWithout.Record.TargetDatasourceUID) + }) } diff --git a/pkg/services/provisioning/alerting/rules_types.go b/pkg/services/provisioning/alerting/rules_types.go index bac091ababa..46dbbbd6342 100644 --- a/pkg/services/provisioning/alerting/rules_types.go +++ b/pkg/services/provisioning/alerting/rules_types.go @@ -303,13 +303,15 @@ func (nsV1 *NotificationSettingsV1) mapToModel() (models.NotificationSettings, e } type RecordV1 struct { - Metric values.StringValue `json:"metric" yaml:"metric"` - From values.StringValue `json:"from" yaml:"from"` + Metric values.StringValue `json:"metric" yaml:"metric"` + From values.StringValue `json:"from" yaml:"from"` + TargetDatasourceUID values.StringValue `json:"targetDatasourceUid" yaml:"targetDatasourceUid"` } func (record *RecordV1) mapToModel() (models.Record, error) { return models.Record{ - Metric: record.Metric.Value(), - From: record.From.Value(), + Metric: record.Metric.Value(), + From: record.From.Value(), + TargetDatasourceUID: record.TargetDatasourceUID.Value(), }, nil } diff --git a/pkg/services/provisioning/alerting/rules_types_test.go b/pkg/services/provisioning/alerting/rules_types_test.go index c2cff56e419..51db78097ad 100644 --- a/pkg/services/provisioning/alerting/rules_types_test.go +++ b/pkg/services/provisioning/alerting/rules_types_test.go @@ -208,6 +208,15 @@ func TestRecordingRules(t *testing.T) { _, err := rule.mapToModel(1) require.NoError(t, err) }) + + t.Run("a valid rule with empty targetDatasourceUid should not error", func(t *testing.T) { + rule := validRecordingRuleV1(t) + rule.Record.TargetDatasourceUID = stringToStringValue("") + model, err := rule.mapToModel(1) + require.NoError(t, err) + require.NotNil(t, model.Record) + require.Equal(t, "", model.Record.TargetDatasourceUID) + }) } func TestNotificationsSettingsV1MapToModel(t *testing.T) { @@ -307,80 +316,43 @@ func TestNotificationsSettingsV1MapToModel(t *testing.T) { func validRuleGroupV1(t *testing.T) AlertRuleGroupV1 { t.Helper() - var ( - orgID values.Int64Value - name values.StringValue - folder values.StringValue - interval values.StringValue - ) + + var orgID values.Int64Value err := yaml.Unmarshal([]byte("1"), &orgID) require.NoError(t, err) - err = yaml.Unmarshal([]byte("Test"), &name) - require.NoError(t, err) - err = yaml.Unmarshal([]byte("Test"), &folder) - require.NoError(t, err) - err = yaml.Unmarshal([]byte("10s"), &interval) - require.NoError(t, err) + return AlertRuleGroupV1{ OrgID: orgID, - Name: name, - Folder: folder, - Interval: interval, + Name: stringToStringValue("Test"), + Folder: stringToStringValue("Test"), + Interval: stringToStringValue("10s"), Rules: []AlertRuleV1{}, } } func validRuleV1(t *testing.T) AlertRuleV1 { t.Helper() - var ( - title values.StringValue - uid values.StringValue - forDuration values.StringValue - condition values.StringValue - ) - err := yaml.Unmarshal([]byte("test"), &title) - require.NoError(t, err) - err = yaml.Unmarshal([]byte("test_uid"), &uid) - require.NoError(t, err) - err = yaml.Unmarshal([]byte("10s"), &forDuration) - require.NoError(t, err) - err = yaml.Unmarshal([]byte("A"), &condition) - require.NoError(t, err) + return AlertRuleV1{ - Title: title, - UID: uid, - For: forDuration, - Condition: condition, + Title: stringToStringValue("test"), + UID: stringToStringValue("test_uid"), + For: stringToStringValue("10s"), + Condition: stringToStringValue("A"), Data: []QueryV1{{}}, } } func validRecordingRuleV1(t *testing.T) AlertRuleV1 { t.Helper() - var ( - title values.StringValue - uid values.StringValue - forDuration values.StringValue - metric values.StringValue - from values.StringValue - ) - err := yaml.Unmarshal([]byte("test"), &title) - require.NoError(t, err) - err = yaml.Unmarshal([]byte("test_uid"), &uid) - require.NoError(t, err) - err = yaml.Unmarshal([]byte("10s"), &forDuration) - require.NoError(t, err) - err = yaml.Unmarshal([]byte("test_metric"), &metric) - require.NoError(t, err) - err = yaml.Unmarshal([]byte("A"), &from) - require.NoError(t, err) + return AlertRuleV1{ - Title: title, - UID: uid, - For: forDuration, + Title: stringToStringValue("test"), + UID: stringToStringValue("test_uid"), + For: stringToStringValue("10s"), Record: &RecordV1{ - Metric: metric, - From: from, + Metric: stringToStringValue("test_metric"), + From: stringToStringValue("A"), + TargetDatasourceUID: stringToStringValue("test_target_datasource"), }, Data: []QueryV1{{}}, } diff --git a/pkg/services/provisioning/alerting/testdata/alert_rules/recording-rules/with-target-datasource.yml b/pkg/services/provisioning/alerting/testdata/alert_rules/recording-rules/with-target-datasource.yml new file mode 100644 index 00000000000..582ddf730ae --- /dev/null +++ b/pkg/services/provisioning/alerting/testdata/alert_rules/recording-rules/with-target-datasource.yml @@ -0,0 +1,26 @@ +apiVersion: 1 +groups: + - name: recording_rules_group + folder: my_folder + interval: 1m + rules: + - uid: recording_rule_with_target + title: my_recording_rule_with_target + condition: A + data: + - refId: A + queryType: '' + relativeTimeRange: + from: 600 + to: 0 + datasourceUid: prometheus-uid + model: + expr: up{instance="localhost:9090"} + instant: true + intervalMs: 1000 + maxDataPoints: 43200 + refId: A + record: + metric: my_recorded_metric + from: A + targetDatasourceUid: mimir-uid diff --git a/pkg/services/provisioning/alerting/testdata/alert_rules/recording-rules/without-target-datasource.yml b/pkg/services/provisioning/alerting/testdata/alert_rules/recording-rules/without-target-datasource.yml new file mode 100644 index 00000000000..0b8b2760988 --- /dev/null +++ b/pkg/services/provisioning/alerting/testdata/alert_rules/recording-rules/without-target-datasource.yml @@ -0,0 +1,25 @@ +apiVersion: 1 +groups: + - name: recording_rules_group_no_target + folder: my_folder + interval: 1m + rules: + - uid: recording_rule_without_target + title: my_recording_rule_without_target + condition: A + data: + - refId: A + queryType: '' + relativeTimeRange: + from: 600 + to: 0 + datasourceUid: prometheus-uid + model: + expr: rate(http_requests_total[5m]) + instant: true + intervalMs: 1000 + maxDataPoints: 43200 + refId: A + record: + metric: http_requests_rate + from: A diff --git a/pkg/tests/api/alerting/test-data/rulegroup-1-export.json b/pkg/tests/api/alerting/test-data/rulegroup-1-export.json index 18d8b8cea40..f8c01a3d883 100644 --- a/pkg/tests/api/alerting/test-data/rulegroup-1-export.json +++ b/pkg/tests/api/alerting/test-data/rulegroup-1-export.json @@ -72,6 +72,33 @@ }, "isPaused": false, "missing_series_evals_to_resolve": 2 + }, + { + "uid": "", + "title": "RecordingRule1", + "data": [ + { + "refId": "A", + "relativeTimeRange": { + "from": 0, + "to": 0 + }, + "datasourceUid": "__expr__", + "model": { + "expression": "1 + 1", + "intervalMs": 1000, + "maxDataPoints": 43200, + "refId": "A", + "type": "math" + } + } + ], + "isPaused": false, + "record": { + "metric": "test_metric", + "from": "A", + "targetDatasourceUid": "test-datasource-uid" + } } ] } diff --git a/pkg/tests/api/alerting/test-data/rulegroup-1-get.json b/pkg/tests/api/alerting/test-data/rulegroup-1-get.json index 333bd32c0d1..d392254d756 100644 --- a/pkg/tests/api/alerting/test-data/rulegroup-1-get.json +++ b/pkg/tests/api/alerting/test-data/rulegroup-1-get.json @@ -97,6 +97,44 @@ }, "missing_series_evals_to_resolve": 2 } + }, + { + "expr": "", + "for": "0s", + "keep_firing_for": "0s", + "grafana_alert": { + "title": "RecordingRule1", + "data": [ + { + "refId": "A", + "queryType": "", + "relativeTimeRange": { + "from": 0, + "to": 0 + }, + "datasourceUid": "__expr__", + "model": { + "expression": "1 + 1", + "intervalMs": 1000, + "maxDataPoints": 43200, + "type": "math" + } + } + ], + "updated": "2023-09-29T17:37:19Z", + "intervalSeconds": 60, + "version": 1, + "uid": "", + "namespace_uid": "", + "rule_group": "Group1", + "is_paused": false, + "record": { + "metric": "test_metric", + "from": "A", + "target_datasource_uid": "test-datasource-uid" + }, + "metadata": {} + } } ] } diff --git a/pkg/tests/api/alerting/test-data/rulegroup-1-post.json b/pkg/tests/api/alerting/test-data/rulegroup-1-post.json index f9f1441eb18..e020299fc42 100644 --- a/pkg/tests/api/alerting/test-data/rulegroup-1-post.json +++ b/pkg/tests/api/alerting/test-data/rulegroup-1-post.json @@ -53,6 +53,26 @@ "exec_err_state": "Alerting", "missing_series_evals_to_resolve": 2 } + }, + { + "grafana_alert": { + "title": "RecordingRule1", + "data": [ + { + "refId": "A", + "datasourceUid": "__expr__", + "model": { + "expression": "1 + 1", + "type": "math" + } + } + ], + "record": { + "metric": "test_metric", + "from": "A", + "target_datasource_uid": "test-datasource-uid" + } + } } ] } diff --git a/pkg/tests/api/alerting/testing.go b/pkg/tests/api/alerting/testing.go index b49e61d3a4e..3bae37f209a 100644 --- a/pkg/tests/api/alerting/testing.go +++ b/pkg/tests/api/alerting/testing.go @@ -249,6 +249,7 @@ func convertGettableGrafanaRuleToPostable(gettable *apimodels.GettableGrafanaRul ExecErrState: gettable.ExecErrState, IsPaused: &gettable.IsPaused, NotificationSettings: gettable.NotificationSettings, + Record: gettable.Record, Metadata: gettable.Metadata, } } From 521cc11994369c21feb265203bfeda3d3f9d5583 Mon Sep 17 00:00:00 2001 From: Kristina Demeshchik Date: Tue, 23 Dec 2025 05:41:49 -0500 Subject: [PATCH 075/229] Dashboard Outline: Differentiate hover styles between edit and view modes (#115646) --- .../dashboard-scene/edit-pane/DashboardOutline.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx index 10bb180a4b1..b543b04537c 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx @@ -94,7 +94,11 @@ function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index } // eslint-disable-next-line @typescript-eslint/consistent-type-assertions style={{ '--depth': depth } as React.CSSProperties} > -
+
{isContainer && ( + + )} {loading && } {/* TODO: Better empty state https://github.com/grafana/grafana/issues/114804 */} {!loading && recentDashboards.length === 0 && ( diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 807d6fbfa4b..85563c43905 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -3748,6 +3748,8 @@ "recently-viewed": { "clear": "Clear history", "empty": "Nothing viewed yet", + "error": "Recently viewed dashboards couldn’t be loaded.", + "retry": "Retry", "title": "Recently viewed" }, "restore": { From 47436a3eebb64f45d2be85181585f503a68613f3 Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Tue, 23 Dec 2025 16:16:48 +0200 Subject: [PATCH 078/229] Provisioning: Fix settings error loop (#115677) --- .../NestedFolderPicker/FolderRepo.tsx | 35 +++++++++++-------- .../components/BrowseView.tsx | 2 +- .../hooks/useGetResourceRepositoryView.ts | 7 ++-- .../hooks/useIsProvisionedInstance.ts | 19 +++++++--- 4 files changed, 40 insertions(+), 23 deletions(-) diff --git a/public/app/core/components/NestedFolderPicker/FolderRepo.tsx b/public/app/core/components/NestedFolderPicker/FolderRepo.tsx index 153becab1b9..9de19c4fcfe 100644 --- a/public/app/core/components/NestedFolderPicker/FolderRepo.tsx +++ b/public/app/core/components/NestedFolderPicker/FolderRepo.tsx @@ -14,13 +14,12 @@ export interface Props { } export const FolderRepo = memo(function FolderRepo({ folder }: Props) { - // skip rendering if: - // folder is not present - // folder have parentUID - // folder is not managed - // if whole instance is provisioned - const isProvisionedInstance = useIsProvisionedInstance(); - const skipRender = getShouldSkipRender(folder, isProvisionedInstance); + // Check if we can skip early without needing the useIsProvisionedInstance query + // This reduces RTK Query subscriptions and prevents re-render loops on API errors + const canSkipEarly = getCanSkipEarly(folder); + + const isProvisionedInstance = useIsProvisionedInstance({ skip: canSkipEarly }); + const skipRender = canSkipEarly || isProvisionedInstance; const { isReadOnlyRepo, repoType } = useGetResourceRepositoryView({ folderName: skipRender ? undefined : folder?.uid, @@ -51,11 +50,19 @@ export const FolderRepo = memo(function FolderRepo({ folder }: Props) { ); }); -function getShouldSkipRender(folder: FolderDTO | DashboardViewItem | undefined, isProvisionedInstance?: boolean) { - // Skip render if parentUID is present, then we should skip rendering. we only display icon for root folders - const hasParent = folder && Boolean('parentUID' in folder && folder.parentUID); - // Skip render if folder is not managed by Repo - const isNotManaged = folder && folder.managedBy !== ManagerKind.Repo; - - return !folder || hasParent || isNotManaged || isProvisionedInstance; +// Check conditions that don't require the useIsProvisionedInstance hook +function getCanSkipEarly(folder: FolderDTO | DashboardViewItem | undefined): boolean { + if (!folder) { + return true; + } + // Skip render if parentUID is present - we only display icon for root folders + const hasParent = Boolean('parentUID' in folder && folder.parentUID); + if (hasParent) { + return true; + } + const isNotManaged = folder.managedBy !== ManagerKind.Repo; + if (isNotManaged) { + return true; + } + return false; } diff --git a/public/app/features/browse-dashboards/components/BrowseView.tsx b/public/app/features/browse-dashboards/components/BrowseView.tsx index b28204f4fe2..0b42b79f9ae 100644 --- a/public/app/features/browse-dashboards/components/BrowseView.tsx +++ b/public/app/features/browse-dashboards/components/BrowseView.tsx @@ -43,10 +43,10 @@ export function BrowseView({ folderUID, width, height, permissions, isReadOnlyRe const selectedItems = useCheckboxSelectionState(); const childrenByParentUID = useChildrenByParentUIDState(); const canSelect = canSelectItems(permissions); - const isProvisionedInstance = useIsProvisionedInstance(); const provisioningEnabled = config.featureToggles.provisioning; const hasNoRole = contextSrv.user.orgRole === OrgRole.None; const { data: settingsData } = useGetFrontendSettingsQuery(!provisioningEnabled || hasNoRole ? skipToken : undefined); + const isProvisionedInstance = useIsProvisionedInstance({ settings: settingsData }); const rootItems = useSelector(rootItemsSelector); const [, stateManager] = useSearchStateManager(); diff --git a/public/app/features/provisioning/hooks/useGetResourceRepositoryView.ts b/public/app/features/provisioning/hooks/useGetResourceRepositoryView.ts index d612be620dc..9dc8e2b51c1 100644 --- a/public/app/features/provisioning/hooks/useGetResourceRepositoryView.ts +++ b/public/app/features/provisioning/hooks/useGetResourceRepositoryView.ts @@ -34,9 +34,10 @@ export const useGetResourceRepositoryView = ({ const hasNoRole = contextSrv.user.orgRole === OrgRole.None; const provisioningEnabled = config.featureToggles.provisioning; - const { data: settingsData, isLoading: isSettingsLoading } = useGetFrontendSettingsQuery( - !provisioningEnabled || skipQuery || hasNoRole ? skipToken : undefined - ); + const shouldSkipSettings = !provisioningEnabled || skipQuery || hasNoRole || (!name && !folderName); + const settingsQueryArg = shouldSkipSettings ? skipToken : undefined; + + const { data: settingsData, isLoading: isSettingsLoading } = useGetFrontendSettingsQuery(settingsQueryArg); const skipFolderQuery = !folderName || !provisioningEnabled || skipQuery || hasNoRole; const { data: folder, isLoading: isFolderLoading } = useGetFolderQuery( diff --git a/public/app/features/provisioning/hooks/useIsProvisionedInstance.ts b/public/app/features/provisioning/hooks/useIsProvisionedInstance.ts index 1c4ba12b8e4..61fcbb73da6 100644 --- a/public/app/features/provisioning/hooks/useIsProvisionedInstance.ts +++ b/public/app/features/provisioning/hooks/useIsProvisionedInstance.ts @@ -5,13 +5,22 @@ import { config } from '@grafana/runtime'; import { RepositoryViewList, useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1'; import { contextSrv } from 'app/core/services/context_srv'; -export function useIsProvisionedInstance(settings?: RepositoryViewList) { +interface UseIsProvisionedInstanceOptions { + settings?: RepositoryViewList; + skip?: boolean; +} + +export function useIsProvisionedInstance(options: UseIsProvisionedInstanceOptions = {}) { + const { settings, skip: skipQuery } = options; const hasNoRole = contextSrv.user.orgRole === OrgRole.None; - const skip = !config.featureToggles.provisioning || hasNoRole; + const skip = !config.featureToggles.provisioning || hasNoRole || skipQuery; const settingsQuery = useGetFrontendSettingsQuery(settings || skip ? skipToken : undefined); - if (!settings) { - settings = settingsQuery.data; + + if (settingsQuery.isError) { + return false; } - return settings?.items?.some((item) => item.target === 'instance'); + + const effectiveSettings = settings ?? settingsQuery.data; + return effectiveSettings?.items?.some((item) => item.target === 'instance'); } From 45f665d203530292c038406a953913f92064be92 Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Tue, 23 Dec 2025 15:24:53 +0100 Subject: [PATCH 079/229] Alerting: Config option to set default datasource in Prometheus rule import (#115665) What is this feature? Add a config option to set data source to imported rules when X-Grafana-Alerting-Datasource-UID is not present. Why do we need this feature? Currently mimirtool requires passing --extra-headers 'X-Grafana-Alerting-Datasource-UID: {uid}' when used with Grafana. This config option allows to specify a default, which is used when the header is missing, making it easier to use and more similar to the case when it's used with Mimir. --- conf/defaults.ini | 5 ++ conf/sample.ini | 5 ++ .../alerting-rules/alerting-migration.md | 2 + .../setup-grafana/configure-grafana/_index.md | 4 ++ .../ngalert/api/api_convert_prometheus.go | 3 + .../api/api_convert_prometheus_test.go | 60 +++++++++++++++++++ pkg/setting/setting_unified_alerting.go | 5 +- 7 files changed, 83 insertions(+), 1 deletion(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index 28cd0420eb2..363ca39d0c4 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -1653,6 +1653,11 @@ loki_basic_auth_password = # Accepts duration formats like: 30s, 1m, 1h. rule_query_offset = 1m +# Default data source UID to use for query execution when importing Prometheus rules. +# This default is used when the X-Grafana-Alerting-Datasource-UID header is not provided. +# If not set, the header becomes required. +default_datasource_uid = + [recording_rules] # Enable recording rules. enabled = true diff --git a/conf/sample.ini b/conf/sample.ini index 0bb5b82fdc9..530b14c87ac 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -1615,6 +1615,11 @@ max_annotations_to_keep = # Accepts duration formats like: 30s, 1m, 1h. rule_query_offset = 1m +# Default data source UID to use for query execution when importing Prometheus rules. +# This default is used when the X-Grafana-Alerting-Datasource-UID header is not provided. +# If not set, the header becomes required. +default_datasource_uid = + #################################### Recording Rules ##################### [recording_rules] # Enable recording rules. diff --git a/docs/sources/alerting/alerting-rules/alerting-migration.md b/docs/sources/alerting/alerting-rules/alerting-migration.md index 3afa3aec453..ab2a5e995cd 100644 --- a/docs/sources/alerting/alerting-rules/alerting-migration.md +++ b/docs/sources/alerting/alerting-rules/alerting-migration.md @@ -242,6 +242,8 @@ Set to `true` to import recording rules in paused state. The UID of the data source to use for alert rule queries. +If not specified in the header, Grafana uses the configured default from `unified_alerting.prometheus_conversion.default_datasource_uid`. If neither the header nor the configuration option is provided, the request fails. + #### `X-Grafana-Alerting-Target-Datasource-UID` The UID of the target data source for recording rules. If not specified, the value from `X-Grafana-Alerting-Datasource-UID` is used. diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index 05d9cb66228..67c361b2bdc 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -2052,6 +2052,10 @@ This section applies only to rules imported as Grafana-managed rules. For more i Set the query offset to imported Grafana-managed rules when `query_offset` is not defined in the original rule group configuration. The default value is `1m`. +#### `default_datasource_uid` + +Set the default data source UID to use for query execution when importing Prometheus rules. Grafana uses this default when the `X-Grafana-Alerting-Datasource-UID` header isn't provided during import. If this option isn't set, the header becomes required. The default value is empty. +
### `[annotations]` diff --git a/pkg/services/ngalert/api/api_convert_prometheus.go b/pkg/services/ngalert/api/api_convert_prometheus.go index 79198ababf1..b6849f55dfc 100644 --- a/pkg/services/ngalert/api/api_convert_prometheus.go +++ b/pkg/services/ngalert/api/api_convert_prometheus.go @@ -375,6 +375,9 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusPostRuleGroups(c *context } datasourceUID := strings.TrimSpace(c.Req.Header.Get(datasourceUIDHeader)) + if datasourceUID == "" { + datasourceUID = srv.cfg.PrometheusConversion.DefaultDatasourceUID + } if datasourceUID == "" { return response.Err(errDatasourceUIDHeaderMissing) } diff --git a/pkg/services/ngalert/api/api_convert_prometheus_test.go b/pkg/services/ngalert/api/api_convert_prometheus_test.go index b4377431249..74e851ed195 100644 --- a/pkg/services/ngalert/api/api_convert_prometheus_test.go +++ b/pkg/services/ngalert/api/api_convert_prometheus_test.go @@ -75,6 +75,46 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) { require.Contains(t, string(response.Body()), "Missing datasource UID header") }) + t.Run("without datasource UID header but with config default should succeed", func(t *testing.T) { + srv, _, ruleStore := createConvertPrometheusSrv(t) + // Set the config default + srv.cfg.PrometheusConversion.DefaultDatasourceUID = existingDSUID + + rc := createRequestCtx() + rc.Req.Header.Set(datasourceUIDHeader, "") + + response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup) + + require.Equal(t, http.StatusAccepted, response.Status()) + + // Verify that the config default datasource was used + assertRulesUseDatasource(t, ruleStore, existingDSUID, 2) + }) + + t.Run("header should take precedence over config default", func(t *testing.T) { + srv, dsCache, ruleStore := createConvertPrometheusSrv(t) + // Add another datasource + anotherDS := &datasources.DataSource{ + UID: "another-ds", + Type: datasources.DS_PROMETHEUS, + } + dsCache.DataSources = append(dsCache.DataSources, anotherDS) + + // Set the config default to one DS + srv.cfg.PrometheusConversion.DefaultDatasourceUID = "another-ds" + + // But use the header to specify a different one + rc := createRequestCtx() + rc.Req.Header.Set(datasourceUIDHeader, existingDSUID) + + response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup) + + require.Equal(t, http.StatusAccepted, response.Status()) + + // Verify that the header datasource was used, not the config default + assertRulesUseDatasource(t, ruleStore, existingDSUID, 2) + }) + t.Run("with invalid datasource should return error", func(t *testing.T) { srv, _, _ := createConvertPrometheusSrv(t) rc := createRequestCtx() @@ -1761,6 +1801,26 @@ func createRequestCtx() *contextmodel.ReqContext { } } +// assertRulesUseDatasource retrieves all alert rules from the store and verifies they use the expected datasource +func assertRulesUseDatasource(t *testing.T, ruleStore *fakes.RuleStore, expectedDatasourceUID string, expectedRuleCount int) { + t.Helper() + + rules, err := ruleStore.ListAlertRules(context.Background(), &models.ListAlertRulesQuery{ + OrgID: 1, + }) + require.NoError(t, err) + require.Len(t, rules, expectedRuleCount) + + for _, rule := range rules { + if rule.Record == nil { + require.NotEmpty(t, rule.Data) + require.Equal(t, expectedDatasourceUID, rule.Data[0].DatasourceUID, rule.Title, expectedDatasourceUID) + } else { + require.Equal(t, expectedDatasourceUID, rule.Record.TargetDatasourceUID) + } + } +} + // Test parseBooleanHeader function which handles boolean header values func TestParseBooleanHeader(t *testing.T) { headerName := "X-Test-Header" diff --git a/pkg/setting/setting_unified_alerting.go b/pkg/setting/setting_unified_alerting.go index 0733e8241e6..743f386ff52 100644 --- a/pkg/setting/setting_unified_alerting.go +++ b/pkg/setting/setting_unified_alerting.go @@ -190,6 +190,8 @@ type UnifiedAlertingReservedLabelSettings struct { type UnifiedAlertingPrometheusConversionSettings struct { // RuleQueryOffset defines a time offset to apply to rule queries during conversion from Prometheus to Grafana format RuleQueryOffset time.Duration + // DefaultDatasourceUID is the default datasource UID to use when converting Prometheus rules if not specified via header + DefaultDatasourceUID string } type UnifiedAlertingLokiSettings struct { @@ -536,7 +538,8 @@ func (cfg *Cfg) ReadUnifiedAlertingSettings(iniFile *ini.File) error { prometheusConversion := iniFile.Section("unified_alerting.prometheus_conversion") uaCfg.PrometheusConversion = UnifiedAlertingPrometheusConversionSettings{ - RuleQueryOffset: prometheusConversion.Key("rule_query_offset").MustDuration(time.Minute), + RuleQueryOffset: prometheusConversion.Key("rule_query_offset").MustDuration(time.Minute), + DefaultDatasourceUID: prometheusConversion.Key("default_datasource_uid").MustString(""), } rr := iniFile.Section("recording_rules") From 0a0f92e85ea6d319c8e7501e435672608e3e3884 Mon Sep 17 00:00:00 2001 From: Sergej-Vlasov <37613182+Sergej-Vlasov@users.noreply.github.com> Date: Tue, 23 Dec 2025 15:52:50 +0000 Subject: [PATCH 080/229] InspectJsonTab: Force render the layout after change to reflect new gridPos (#115688) force render the layout after inspect panel change to account for gridPos change --- .../inspect/InspectJsonTab.test.tsx | 47 ++++++++++++++++++- .../inspect/InspectJsonTab.tsx | 7 +++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/public/app/features/dashboard-scene/inspect/InspectJsonTab.test.tsx b/public/app/features/dashboard-scene/inspect/InspectJsonTab.test.tsx index 36337a6ddef..786ff98e236 100644 --- a/public/app/features/dashboard-scene/inspect/InspectJsonTab.test.tsx +++ b/public/app/features/dashboard-scene/inspect/InspectJsonTab.test.tsx @@ -12,7 +12,7 @@ import { } from '@grafana/data'; import { getPanelPlugin } from '@grafana/data/test'; import { setPluginImportUtils, setRunRequest } from '@grafana/runtime'; -import { SceneCanvasText, SceneDataTransformer, SceneQueryRunner, VizPanel } from '@grafana/scenes'; +import { SceneCanvasText, SceneDataTransformer, SceneGridLayout, SceneQueryRunner, VizPanel } from '@grafana/scenes'; import * as libpanels from 'app/features/library-panels/state/api'; import { getStandardTransformers } from 'app/features/transformers/standardTransformers'; @@ -183,6 +183,51 @@ describe('InspectJsonTab', () => { expect(tab.state.onClose).toHaveBeenCalled(); }); + it('Can update gridPos and forces layout re-render', async () => { + const { tab, panel, scene } = await buildTestScene(); + + // Get the layout manager and spy on the grid's forceRender + const layoutManager = scene.state.body as DefaultGridLayoutManager; + const grid = layoutManager.state.grid as SceneGridLayout; + const forceRenderSpy = jest.spyOn(grid, 'forceRender'); + + const originalGridItem = panel.parent as DashboardGridItem; + expect(originalGridItem.state.x).toBe(0); + expect(originalGridItem.state.y).toBe(0); + expect(originalGridItem.state.width).toBe(8); + expect(originalGridItem.state.height).toBe(10); + + tab.onCodeEditorBlur(`{ + "id": 12, + "type": "table", + "title": "Panel A", + "gridPos": { + "x": 5, + "y": 10, + "w": 12, + "h": 8 + }, + "options": {}, + "fieldConfig": {}, + "transformations": [], + "transparent": false + }`); + + tab.onApplyChange(); + + const panel2 = findVizPanelByKey(scene, panel.state.key)!; + const gridItem = panel2.parent as DashboardGridItem; + + // Verify all gridPos properties are updated + expect(gridItem.state.x).toBe(5); + expect(gridItem.state.y).toBe(10); + expect(gridItem.state.width).toBe(12); + expect(gridItem.state.height).toBe(8); + + // Verify forceRender was called on the layout to apply position changes + expect(forceRenderSpy).toHaveBeenCalled(); + }); + it('Can show panel json for V2 dashboard specification', async () => { const { tab } = await buildTestSceneWithV2Spec(); diff --git a/public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx b/public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx index 4fd9bc8b9a6..60f3073dea1 100644 --- a/public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx +++ b/public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx @@ -9,6 +9,7 @@ import { SceneDataTransformer, sceneGraph, SceneGridItemStateLike, + SceneGridLayout, SceneObjectBase, SceneObjectRef, SceneObjectState, @@ -168,6 +169,12 @@ export class InspectJsonTab extends SceneObjectBase { panel.parent.setState(newState); + // Force the grid layout to re-render with the new positions + const layout = sceneGraph.getLayout(panel); + if (layout instanceof SceneGridLayout) { + layout.forceRender(); + } + //Report relevant updates reportPanelInspectInteraction(InspectTab.JSON, 'apply', { panel_type_changed: panel.state.pluginId !== panelModel.type, From a1389bc17319a4d1069d75ebc315eaeee3703ff2 Mon Sep 17 00:00:00 2001 From: "alerting-team[bot]" <158350966+alerting-team[bot]@users.noreply.github.com> Date: Tue, 23 Dec 2025 14:46:44 -0500 Subject: [PATCH 081/229] Alerting: Update alerting module to 77a1e2f35be87bebc41a0bf634f336282f0b9b53 (#115498) * [create-pull-request] automated change * Remove IsProtectedField and temp structure * Fix alerting historian * make update-workspace --------- Co-authored-by: yuri-tceretian <25988953+yuri-tceretian@users.noreply.github.com> Co-authored-by: Yuri Tseretyan Co-authored-by: Alexander Akhmetov --- apps/advisor/go.mod | 2 +- apps/advisor/go.sum | 4 +- apps/alerting/historian/go.mod | 2 +- apps/alerting/historian/go.sum | 4 +- .../pkg/app/notification/lokireader.go | 31 ++--- .../pkg/app/notification/lokireader_test.go | 106 ++++++++++-------- apps/iam/go.mod | 2 +- apps/iam/go.sum | 4 +- apps/plugins/go.mod | 2 +- apps/plugins/go.sum | 4 +- go.mod | 2 +- go.sum | 4 +- go.work.sum | 22 ++-- pkg/api/alerting.go | 64 ++--------- pkg/services/ngalert/models/receivers_diff.go | 55 +-------- .../alert-notifiers-v2-snapshot.json | 20 ++++ 16 files changed, 131 insertions(+), 197 deletions(-) diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod index c1b2c7acd25..efc9ed4d500 100644 --- a/apps/advisor/go.mod +++ b/apps/advisor/go.mod @@ -157,7 +157,7 @@ require ( github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/google/wire v0.7.0 // indirect - github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7 // indirect + github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 // indirect github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect diff --git a/apps/advisor/go.sum b/apps/advisor/go.sum index 760641a8857..07730457d60 100644 --- a/apps/advisor/go.sum +++ b/apps/advisor/go.sum @@ -619,8 +619,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7 h1:ZzG/gCclEit9w0QUfQt9GURcOycAIGcsQAhY1u0AEX0= -github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 h1:m5VqerdocNDh1vTi8itJmX9DwonGqj+SfkO0JxPHt0E= +github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= diff --git a/apps/alerting/historian/go.mod b/apps/alerting/historian/go.mod index 0524e1a3852..9a83b79c0f6 100644 --- a/apps/alerting/historian/go.mod +++ b/apps/alerting/historian/go.mod @@ -4,7 +4,7 @@ go 1.25.5 require ( github.com/go-kit/log v0.2.1 - github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7 + github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 github.com/grafana/grafana-app-sdk v0.48.7 github.com/grafana/grafana-app-sdk/logging v0.48.7 diff --git a/apps/alerting/historian/go.sum b/apps/alerting/historian/go.sum index 6e82a1dea7b..17beef468f0 100644 --- a/apps/alerting/historian/go.sum +++ b/apps/alerting/historian/go.sum @@ -243,8 +243,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7 h1:ZzG/gCclEit9w0QUfQt9GURcOycAIGcsQAhY1u0AEX0= -github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 h1:m5VqerdocNDh1vTi8itJmX9DwonGqj+SfkO0JxPHt0E= +github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4= github.com/grafana/grafana-app-sdk v0.48.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfUHB32u2ZMo= diff --git a/apps/alerting/historian/pkg/app/notification/lokireader.go b/apps/alerting/historian/pkg/app/notification/lokireader.go index c26519e59b4..636ae083d91 100644 --- a/apps/alerting/historian/pkg/app/notification/lokireader.go +++ b/apps/alerting/historian/pkg/app/notification/lokireader.go @@ -31,6 +31,10 @@ const ( maxLimit = 1000 Namespace = "grafana" Subsystem = "alerting" + + // LogQL field path for alert rule UID after JSON parsing. + // Loki flattens nested JSON fields with underscores: alert.labels.__alert_rule_uid__ -> alert_labels___alert_rule_uid__ + lokiAlertRuleUIDField = "alert_labels___alert_rule_uid__" ) var ( @@ -111,13 +115,13 @@ func buildQuery(query Query) (string, error) { fmt.Sprintf(`%s=%q`, historian.LabelFrom, historian.LabelFromValue), } - if query.RuleUID != nil { - selectors = append(selectors, - fmt.Sprintf(`%s=%q`, historian.LabelRuleUID, *query.RuleUID)) - } - logql := fmt.Sprintf(`{%s} | json`, strings.Join(selectors, `,`)) + // Add ruleUID filter as JSON line filter if specified. + if query.RuleUID != nil && *query.RuleUID != "" { + logql += fmt.Sprintf(` | %s = %q`, lokiAlertRuleUIDField, *query.RuleUID) + } + // Add receiver filter if specified. if query.Receiver != nil && *query.Receiver != "" { logql += fmt.Sprintf(` | receiver = %q`, *query.Receiver) @@ -211,16 +215,13 @@ func parseLokiEntry(s lokiclient.Sample) (Entry, error) { groupLabels = make(map[string]string) } - alerts := make([]EntryAlert, len(lokiEntry.Alerts)) - for i, a := range lokiEntry.Alerts { - alerts[i] = EntryAlert{ - Status: a.Status, - Labels: a.Labels, - Annotations: a.Annotations, - StartsAt: a.StartsAt, - EndsAt: a.EndsAt, - } - } + alerts := []EntryAlert{{ + Status: lokiEntry.Alert.Status, + Labels: lokiEntry.Alert.Labels, + Annotations: lokiEntry.Alert.Annotations, + StartsAt: lokiEntry.Alert.StartsAt, + EndsAt: lokiEntry.Alert.EndsAt, + }} return Entry{ Timestamp: s.T, diff --git a/apps/alerting/historian/pkg/app/notification/lokireader_test.go b/apps/alerting/historian/pkg/app/notification/lokireader_test.go index 708c9d10df1..c9c35cb1e62 100644 --- a/apps/alerting/historian/pkg/app/notification/lokireader_test.go +++ b/apps/alerting/historian/pkg/app/notification/lokireader_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/grafana/alerting/models" "github.com/grafana/alerting/notify/historian" "github.com/grafana/alerting/notify/historian/lokiclient" "github.com/grafana/grafana-app-sdk/logging" @@ -133,9 +134,8 @@ func TestBuildQuery(t *testing.T) { query: Query{ RuleUID: stringPtr("test-rule-uid"), }, - expected: fmt.Sprintf(`{%s=%q,%s=%q} | json`, - historian.LabelFrom, historian.LabelFromValue, - historian.LabelRuleUID, "test-rule-uid"), + expected: fmt.Sprintf(`{%s=%q} | json | alert_labels___alert_rule_uid__ = "test-rule-uid"`, + historian.LabelFrom, historian.LabelFromValue), }, { name: "query with receiver filter", @@ -143,9 +143,8 @@ func TestBuildQuery(t *testing.T) { RuleUID: stringPtr("test-rule-uid"), Receiver: stringPtr("email-receiver"), }, - expected: fmt.Sprintf(`{%s=%q,%s=%q} | json | receiver = "email-receiver"`, - historian.LabelFrom, historian.LabelFromValue, - historian.LabelRuleUID, "test-rule-uid"), + expected: fmt.Sprintf(`{%s=%q} | json | alert_labels___alert_rule_uid__ = "test-rule-uid" | receiver = "email-receiver"`, + historian.LabelFrom, historian.LabelFromValue), }, { name: "query with status filter", @@ -153,9 +152,8 @@ func TestBuildQuery(t *testing.T) { RuleUID: stringPtr("test-rule-uid"), Status: createStatusPtr(v0alpha1.CreateNotificationqueryRequestNotificationStatusFiring), }, - expected: fmt.Sprintf(`{%s=%q,%s=%q} | json | status = "firing"`, - historian.LabelFrom, historian.LabelFromValue, - historian.LabelRuleUID, "test-rule-uid"), + expected: fmt.Sprintf(`{%s=%q} | json | alert_labels___alert_rule_uid__ = "test-rule-uid" | status = "firing"`, + historian.LabelFrom, historian.LabelFromValue), }, { name: "query with success outcome filter", @@ -163,9 +161,8 @@ func TestBuildQuery(t *testing.T) { RuleUID: stringPtr("test-rule-uid"), Outcome: outcomePtr(v0alpha1.CreateNotificationqueryRequestNotificationOutcomeSuccess), }, - expected: fmt.Sprintf(`{%s=%q,%s=%q} | json | error = ""`, - historian.LabelFrom, historian.LabelFromValue, - historian.LabelRuleUID, "test-rule-uid"), + expected: fmt.Sprintf(`{%s=%q} | json | alert_labels___alert_rule_uid__ = "test-rule-uid" | error = ""`, + historian.LabelFrom, historian.LabelFromValue), }, { name: "query with error outcome filter", @@ -173,9 +170,8 @@ func TestBuildQuery(t *testing.T) { RuleUID: stringPtr("test-rule-uid"), Outcome: outcomePtr(v0alpha1.CreateNotificationqueryRequestNotificationOutcomeError), }, - expected: fmt.Sprintf(`{%s=%q,%s=%q} | json | error != ""`, - historian.LabelFrom, historian.LabelFromValue, - historian.LabelRuleUID, "test-rule-uid"), + expected: fmt.Sprintf(`{%s=%q} | json | alert_labels___alert_rule_uid__ = "test-rule-uid" | error != ""`, + historian.LabelFrom, historian.LabelFromValue), }, { name: "query with many filters", @@ -185,9 +181,8 @@ func TestBuildQuery(t *testing.T) { Status: createStatusPtr(v0alpha1.CreateNotificationqueryRequestNotificationStatusResolved), Outcome: outcomePtr(v0alpha1.CreateNotificationqueryRequestNotificationOutcomeSuccess), }, - expected: fmt.Sprintf(`{%s=%q,%s=%q} | json | receiver = "email-receiver" | status = "resolved" | error = ""`, - historian.LabelFrom, historian.LabelFromValue, - historian.LabelRuleUID, "test-rule-uid"), + expected: fmt.Sprintf(`{%s=%q} | json | alert_labels___alert_rule_uid__ = "test-rule-uid" | receiver = "email-receiver" | status = "resolved" | error = ""`, + historian.LabelFrom, historian.LabelFromValue), }, { name: "query with group label matcher", @@ -277,19 +272,19 @@ func TestParseLokiEntry(t *testing.T) { GroupLabels: map[string]string{ "alertname": "test-alert", }, - Alerts: []historian.NotificationHistoryLokiEntryAlert{ - { - Status: "firing", - Labels: map[string]string{ - "severity": "critical", - }, - Annotations: map[string]string{ - "summary": "Test alert", - }, - StartsAt: now, - EndsAt: now.Add(1 * time.Hour), + Alert: historian.NotificationHistoryLokiEntryAlert{ + Status: "firing", + Labels: map[string]string{ + "severity": "critical", }, + Annotations: map[string]string{ + "summary": "Test alert", + }, + StartsAt: now, + EndsAt: now.Add(1 * time.Hour), }, + AlertIndex: 0, + AlertCount: 1, Retry: false, Duration: 100, PipelineTime: now, @@ -335,7 +330,9 @@ func TestParseLokiEntry(t *testing.T) { Error: "notification failed", GroupKey: "key:thing", GroupLabels: map[string]string{}, - Alerts: []historian.NotificationHistoryLokiEntryAlert{}, + Alert: historian.NotificationHistoryLokiEntryAlert{}, + AlertIndex: 0, + AlertCount: 1, PipelineTime: now, }), }, @@ -347,7 +344,7 @@ func TestParseLokiEntry(t *testing.T) { Outcome: OutcomeError, GroupKey: "key:thing", GroupLabels: map[string]string{}, - Alerts: []EntryAlert{}, + Alerts: []EntryAlert{{}}, Error: stringPtr("notification failed"), PipelineTime: now, }, @@ -365,7 +362,7 @@ func TestParseLokiEntry(t *testing.T) { Status: Status("firing"), Outcome: OutcomeSuccess, GroupLabels: map[string]string{}, - Alerts: []EntryAlert{}, + Alerts: []EntryAlert{{}}, PipelineTime: now, }, }, @@ -448,7 +445,9 @@ func TestLokiReader_RunQuery(t *testing.T) { Receiver: "receiver-1", Status: "firing", GroupLabels: map[string]string{}, - Alerts: []historian.NotificationHistoryLokiEntryAlert{}, + Alert: historian.NotificationHistoryLokiEntryAlert{}, + AlertIndex: 0, + AlertCount: 1, PipelineTime: now, }), }, @@ -459,7 +458,9 @@ func TestLokiReader_RunQuery(t *testing.T) { Receiver: "receiver-3", Status: "firing", GroupLabels: map[string]string{}, - Alerts: []historian.NotificationHistoryLokiEntryAlert{}, + Alert: historian.NotificationHistoryLokiEntryAlert{}, + AlertIndex: 0, + AlertCount: 1, PipelineTime: now, }), }, @@ -474,7 +475,9 @@ func TestLokiReader_RunQuery(t *testing.T) { Receiver: "receiver-2", Status: "firing", GroupLabels: map[string]string{}, - Alerts: []historian.NotificationHistoryLokiEntryAlert{}, + Alert: historian.NotificationHistoryLokiEntryAlert{}, + AlertIndex: 0, + AlertCount: 1, PipelineTime: now, }), }, @@ -546,19 +549,19 @@ func createMockLokiResponse(timestamp time.Time) lokiclient.QueryRes { GroupLabels: map[string]string{ "alertname": "test-alert", }, - Alerts: []historian.NotificationHistoryLokiEntryAlert{ - { - Status: "firing", - Labels: map[string]string{ - "severity": "critical", - }, - Annotations: map[string]string{ - "summary": "Test alert", - }, - StartsAt: timestamp, - EndsAt: timestamp.Add(1 * time.Hour), + Alert: historian.NotificationHistoryLokiEntryAlert{ + Status: "firing", + Labels: map[string]string{ + "severity": "critical", }, + Annotations: map[string]string{ + "summary": "Test alert", + }, + StartsAt: timestamp, + EndsAt: timestamp.Add(1 * time.Hour), }, + AlertIndex: 0, + AlertCount: 1, Retry: false, Duration: 100, PipelineTime: timestamp, @@ -587,10 +590,19 @@ func createLokiEntryJSONWithNilLabels(t *testing.T, timestamp time.Time) string "status": "firing", "error": "", "groupLabels": null, - "alerts": [], + "alert": {}, + "alertIndex": 0, + "alertCount": 1, "retry": false, "duration": 0, "pipelineTime": "%s" }`, timestamp.Format(time.RFC3339Nano)) return jsonStr } + +func TestRuleUIDLabelConstant(t *testing.T) { + // Verify that models.RuleUIDLabel has the expected value. + // If this changes in the alerting module, our LogQL field path constant will be incorrect + // and filtering for a single alert rule by its UID will break. + assert.Equal(t, "__alert_rule_uid__", models.RuleUIDLabel) +} diff --git a/apps/iam/go.mod b/apps/iam/go.mod index 24769ca825f..8a6cec152cd 100644 --- a/apps/iam/go.mod +++ b/apps/iam/go.mod @@ -223,7 +223,7 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/gorilla/mux v1.8.1 // indirect - github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7 // indirect + github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 // indirect github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect diff --git a/apps/iam/go.sum b/apps/iam/go.sum index 4584bbd9cc1..00d85d14de4 100644 --- a/apps/iam/go.sum +++ b/apps/iam/go.sum @@ -827,8 +827,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7 h1:ZzG/gCclEit9w0QUfQt9GURcOycAIGcsQAhY1u0AEX0= -github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 h1:m5VqerdocNDh1vTi8itJmX9DwonGqj+SfkO0JxPHt0E= +github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= diff --git a/apps/plugins/go.mod b/apps/plugins/go.mod index edcf18ea3e3..62f8f4edf0f 100644 --- a/apps/plugins/go.mod +++ b/apps/plugins/go.mod @@ -90,7 +90,7 @@ require ( github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7 // indirect + github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 // indirect github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect diff --git a/apps/plugins/go.sum b/apps/plugins/go.sum index c5fbc7a39a5..f2dbfce834a 100644 --- a/apps/plugins/go.sum +++ b/apps/plugins/go.sum @@ -213,8 +213,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7 h1:ZzG/gCclEit9w0QUfQt9GURcOycAIGcsQAhY1u0AEX0= -github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 h1:m5VqerdocNDh1vTi8itJmX9DwonGqj+SfkO0JxPHt0E= +github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= diff --git a/go.mod b/go.mod index fb1ab1ce189..492087be19f 100644 --- a/go.mod +++ b/go.mod @@ -87,7 +87,7 @@ require ( github.com/googleapis/gax-go/v2 v2.15.0 // @grafana/grafana-backend-group github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // @grafana/grafana-app-platform-squad - github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7 // @grafana/alerting-backend + github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 // @grafana/alerting-backend github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // @grafana/identity-access-team github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // @grafana/identity-access-team github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics diff --git a/go.sum b/go.sum index c1a8d8ad808..9d7d7380b71 100644 --- a/go.sum +++ b/go.sum @@ -1622,8 +1622,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7 h1:ZzG/gCclEit9w0QUfQt9GURcOycAIGcsQAhY1u0AEX0= -github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 h1:m5VqerdocNDh1vTi8itJmX9DwonGqj+SfkO0JxPHt0E= +github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= diff --git a/go.work.sum b/go.work.sum index 73813d12650..ca22b546c86 100644 --- a/go.work.sum +++ b/go.work.sum @@ -793,7 +793,15 @@ github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5 github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs= +github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M= +github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo= +github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc= +github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ= +github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg= +github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8= +github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg= github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= github.com/go-pdf/fpdf v0.6.0 h1:MlgtGIfsdMEEQJr2le6b/HNr1ZlQwxyWr77r2aj2U/8= github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= @@ -982,7 +990,6 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9K github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1/go.mod h1:5SN9VR2LTsRFsrEC6FHgRbTWrTHu6tqPeKxEQv15giM= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 h1:MJG/KsmcqMwFAkh8mTnAwhyKoB+sTAnY4CACC110tbU= github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645/go.mod h1:6iZfnjpejD4L/4DwD7NryNaJyCQdzwWwH2MWhCA90Kw= github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8= @@ -1404,7 +1411,6 @@ github.com/richardartoul/molecule v1.0.0/go.mod h1:uvX/8buq8uVeiZiFht+0lqSLBHF+u github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= @@ -1623,7 +1629,6 @@ go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5queth go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c= go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/collector v0.121.0/go.mod h1:M4TlnmkjIgishm2DNCk9K3hMKTmAsY9w8cNFsp9EchM= go.opentelemetry.io/collector v0.124.0/go.mod h1:QzERYfmHUedawjr8Ph/CBEEkVqWS8IlxRLAZt+KHlCg= go.opentelemetry.io/collector/client v1.29.0/go.mod h1:LCUoEV2KCTKA1i+/txZaGsSPVWUcqeOV6wCfNsAippE= @@ -1839,6 +1844,7 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1: go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= go.opentelemetry.io/contrib/otelconf v0.15.0 h1:BLNiIUsrNcqhSKpsa6CnhE6LdrpY1A8X0szMVsu99eo= go.opentelemetry.io/contrib/otelconf v0.15.0/go.mod h1:OPH1seO5z9dp1P26gnLtoM9ht7JDvh3Ws6XRHuXqImY= go.opentelemetry.io/contrib/propagators/aws v1.37.0 h1:cp8AFiM/qjBm10C/ATIRnEDXpD5MBknrA0ANw4T2/ss= @@ -1910,7 +1916,6 @@ go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v8 go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= -go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= @@ -2118,8 +2123,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0/go. google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M= google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c/go.mod h1:ea2MjsO70ssTfCjiwHgI0ZFqcw45Ksuk2ckf9G468GA= google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw= google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk= +google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:G5IanEx8/PgI9w6CFcYQf7jMtHQhZruvfM1i3qOqk5U= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/bytestream v0.0.0-20250603155806-513f23925822 h1:zWFRixYR5QlotL+Uv3YfsPRENIrQFXiGs+iwqel6fOQ= google.golang.org/genproto/googleapis/bytestream v0.0.0-20250603155806-513f23925822/go.mod h1:h6yxum/C2qRb4txaZRLDHK8RyS0H/o2oEDeKY4onY/Y= @@ -2150,10 +2155,9 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go. google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= @@ -2177,7 +2181,6 @@ google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7E google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0 h1:M1YKkFIboKNieVO5DLUEVzQfGwJD30Nv2jfUgzb5UcE= google.golang.org/grpc/examples v0.0.0-20230224211313-3775f633ce20 h1:MLBCGN1O7GzIx+cBiwfYPwtmZ41U3Mn/cotLJciaArI= google.golang.org/grpc/examples v0.0.0-20230224211313-3775f633ce20/go.mod h1:Nr5H8+MlGWr5+xX/STzdoEqJrO+YteqFbMyCsrb6mH0= @@ -2299,7 +2302,6 @@ sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ih sigs.k8s.io/structured-merge-diff/v4 v4.5.0 h1:nbCitCK2hfnhyiKo6uf2HxUPTCodY6Qaf85SbDIaMBk= sigs.k8s.io/structured-merge-diff/v4 v4.5.0/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= sigs.k8s.io/structured-merge-diff/v6 v6.2.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4= diff --git a/pkg/api/alerting.go b/pkg/api/alerting.go index 8fb2f366bf2..27abb6ebbfc 100644 --- a/pkg/api/alerting.go +++ b/pkg/api/alerting.go @@ -10,7 +10,6 @@ import ( "github.com/grafana/grafana/pkg/api/response" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" - "github.com/grafana/grafana/pkg/services/ngalert/models" ) func (hs *HTTPServer) GetAlertNotifiers() func(*contextmodel.ReqContext) response.Response { @@ -24,13 +23,13 @@ func (hs *HTTPServer) GetAlertNotifiers() func(*contextmodel.ReqContext) respons } type NotifierPlugin struct { - Type string `json:"type"` - TypeAlias string `json:"typeAlias,omitempty"` - Name string `json:"name"` - Heading string `json:"heading"` - Description string `json:"description"` - Info string `json:"info"` - Options []Field `json:"options"` + Type string `json:"type"` + TypeAlias string `json:"typeAlias,omitempty"` + Name string `json:"name"` + Heading string `json:"heading"` + Description string `json:"description"` + Info string `json:"info"` + Options []schema.Field `json:"options"` } result := make([]*NotifierPlugin, 0, len(v2)) @@ -45,56 +44,9 @@ func (hs *HTTPServer) GetAlertNotifiers() func(*contextmodel.ReqContext) respons Description: s.Description, Heading: s.Heading, Info: s.Info, - Options: schemaFieldsToFields(s.Type, nil, v1.Options), + Options: v1.Options, }) } return response.JSON(http.StatusOK, result) } } - -type Field struct { - Element schema.ElementType `json:"element"` - InputType schema.InputType `json:"inputType"` - Label string `json:"label"` - Description string `json:"description"` - Placeholder string `json:"placeholder"` - PropertyName string `json:"propertyName"` - SelectOptions []schema.SelectOption `json:"selectOptions"` - ShowWhen schema.ShowWhen `json:"showWhen"` - Required bool `json:"required"` - Protected bool `json:"protected,omitempty"` - ValidationRule string `json:"validationRule"` - Secure bool `json:"secure"` - DependsOn string `json:"dependsOn"` - SubformOptions []Field `json:"subformOptions"` -} - -func schemaFieldsToFields(iType schema.IntegrationType, parent schema.IntegrationFieldPath, fields []schema.Field) []Field { - if fields == nil { - return nil - } - result := make([]Field, 0, len(fields)) - for _, f := range fields { - result = append(result, schemaFieldToField(iType, parent, f)) - } - return result -} - -func schemaFieldToField(iType schema.IntegrationType, parent schema.IntegrationFieldPath, f schema.Field) Field { - return Field{ - Element: f.Element, - InputType: f.InputType, - Label: f.Label, - Description: f.Description, - Placeholder: f.Placeholder, - PropertyName: f.PropertyName, - SelectOptions: f.SelectOptions, - ShowWhen: f.ShowWhen, - Required: f.Required, - ValidationRule: f.ValidationRule, - Secure: f.Secure, - DependsOn: f.DependsOn, - SubformOptions: schemaFieldsToFields(iType, append(parent, f.PropertyName), f.SubformOptions), - Protected: models.IsProtectedField(iType, append(parent, f.PropertyName)), - } -} diff --git a/pkg/services/ngalert/models/receivers_diff.go b/pkg/services/ngalert/models/receivers_diff.go index bfe9328542f..681f07601d9 100644 --- a/pkg/services/ngalert/models/receivers_diff.go +++ b/pkg/services/ngalert/models/receivers_diff.go @@ -169,62 +169,9 @@ func HasIntegrationsDifferentProtectedFields(existing, incoming *Integration) [] var result []schema.IntegrationFieldPath settingsDiff := diff.GetSettingsPaths() for _, path := range settingsDiff { - if IsProtectedField(incoming.Config.Type(), path) { + if incoming.Config.IsProtectedField(path) { result = append(result, path) } } return result } - -// IsProtectedField returns true if the field at the given path is existing protected one. -// This includes: -// 1. URL fields marked as secure in the schema (e.g., webhook URLs with credentials) -// 2. URL fields NOT marked as secure but could contain credentials (e.g., API endpoints) -func IsProtectedField(integrationType schema.IntegrationType, path schema.IntegrationFieldPath) bool { - str := strings.ToLower(string(integrationType)) - pathStr := path.String() - - switch str { - case "prometheus-alertmanager": - return pathStr == "url" - case "dingding": - return pathStr == "url" // marked as secure - case "discord": - return pathStr == "url" // marked as secure (webhook URL) - case "googlechat": - return pathStr == "url" // marked as secure - case "jira": - return pathStr == "api_url" - case "kafka": - return pathStr == "kafkaRestProxy" - case "line": - return false - case "mqtt": - return pathStr == "brokerUrl" - case "oncall": - return pathStr == "url" - case "opsgenie": - return pathStr == "apiUrl" - case "pagerduty": - return pathStr == "url" - case "sensugo": - return pathStr == "url" - case "slack": - return pathStr == "url" || pathStr == "endpointUrl" - case "teams": - return pathStr == "url" - case "victorops": - return pathStr == "url" // marked as secure - case "webex": - return pathStr == "api_url" - case "webhook": - return pathStr == "url" || - pathStr == "http_config.oauth2.token_url" || - pathStr == "http_config.oauth2.proxy_config.proxy_url" - case "wecom": - return pathStr == "url" || // marked as secure - pathStr == "endpointUrl" - default: - return false - } -} diff --git a/pkg/tests/api/alerting/test-data/alert-notifiers-v2-snapshot.json b/pkg/tests/api/alerting/test-data/alert-notifiers-v2-snapshot.json index d942144ef9c..50c92e4d069 100644 --- a/pkg/tests/api/alerting/test-data/alert-notifiers-v2-snapshot.json +++ b/pkg/tests/api/alerting/test-data/alert-notifiers-v2-snapshot.json @@ -93,6 +93,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": true, "dependsOn": "", @@ -225,6 +226,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": true, "dependsOn": "", @@ -1300,6 +1302,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": true, "dependsOn": "", @@ -1405,6 +1408,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -2476,6 +2480,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -2645,6 +2650,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -2935,6 +2941,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -3139,6 +3146,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -4405,6 +4413,7 @@ "is": "" }, "required": false, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -5334,6 +5343,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -6630,6 +6640,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -6928,6 +6939,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": true, "dependsOn": "token", @@ -6946,6 +6958,7 @@ "is": "" }, "required": false, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -9237,6 +9250,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -11515,6 +11529,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": true, "dependsOn": "", @@ -12308,6 +12323,7 @@ "is": "" }, "required": false, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -13001,6 +13017,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -13443,6 +13460,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -13641,6 +13659,7 @@ "is": "" }, "required": false, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -15072,6 +15091,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": true, "dependsOn": "secret", From f5218b5eb826f1ea8561c24abf679e67a6a9bd88 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Tue, 23 Dec 2025 16:39:30 -0500 Subject: [PATCH 082/229] Sparkline: Add point annotations for some common calcs (#115595) --- .../src/field/fieldDisplay.test.ts | 77 +++++++++- .../grafana-data/src/field/fieldDisplay.ts | 137 +++++++++++------- .../RadialGauge/RadialSparkline.tsx | 2 +- .../src/components/Sparkline/Sparkline.tsx | 10 +- .../src/components/Sparkline/utils.test.ts | 135 ++++++++++++++++- .../src/components/Sparkline/utils.ts | 56 +++++-- 6 files changed, 338 insertions(+), 79 deletions(-) diff --git a/packages/grafana-data/src/field/fieldDisplay.test.ts b/packages/grafana-data/src/field/fieldDisplay.test.ts index 718c0e54430..5ec3ed7ba4f 100644 --- a/packages/grafana-data/src/field/fieldDisplay.test.ts +++ b/packages/grafana-data/src/field/fieldDisplay.test.ts @@ -3,11 +3,18 @@ import { merge } from 'lodash'; import { toDataFrame } from '../dataframe/processDataFrame'; import { createTheme } from '../themes/createTheme'; import { ReducerID } from '../transformations/fieldReducer'; +import { FieldType } from '../types/dataFrame'; import { FieldConfigPropertyItem } from '../types/fieldOverrides'; import { MappingType, SpecialValueMatch, ValueMapping } from '../types/valueMapping'; import { getDisplayProcessor } from './displayProcessor'; -import { fixCellTemplateExpressions, getFieldDisplayValues, GetFieldDisplayValuesOptions } from './fieldDisplay'; +import { + FieldSparkline, + fixCellTemplateExpressions, + getFieldDisplayValues, + GetFieldDisplayValuesOptions, + getSparklineHighlight, +} from './fieldDisplay'; import { standardFieldConfigEditorRegistry } from './standardFieldConfigEditorRegistry'; describe('FieldDisplay', () => { @@ -556,3 +563,71 @@ describe('fixCellTemplateExpressions', () => { ); }); }); + +describe('getSparklineHighlight', () => { + const sparkline: FieldSparkline = { + y: { name: 'A', type: FieldType.number, values: [null, 2, 3, 4, 10, 8, 8, 8, 9, null], config: {} }, + }; + + it.each([ + { + calc: ReducerID.last, + expected: { + type: 'point', + xIdx: 9, + }, + }, + { + calc: ReducerID.max, + expected: { + type: 'point', + xIdx: 4, + }, + }, + { + calc: ReducerID.min, + expected: { + type: 'point', + xIdx: 1, + }, + }, + { + calc: ReducerID.first, + expected: { + type: 'point', + xIdx: 0, + }, + }, + { + calc: ReducerID.firstNotNull, + expected: { + type: 'point', + xIdx: 1, + }, + }, + { + calc: ReducerID.lastNotNull, + expected: { + type: 'point', + xIdx: 8, + }, + }, + { + calc: ReducerID.mean, + expected: { + type: 'line', + y: 6.5, + }, + }, + { + calc: ReducerID.median, + expected: { + type: 'line', + y: 8, + }, + }, + ])('it calculates the correct highlight for the $calc', ({ calc, expected }) => { + const result = getSparklineHighlight(sparkline, calc); + expect(result).toEqual(expected); + }); +}); diff --git a/packages/grafana-data/src/field/fieldDisplay.ts b/packages/grafana-data/src/field/fieldDisplay.ts index 3d82f571926..3496f419395 100644 --- a/packages/grafana-data/src/field/fieldDisplay.ts +++ b/packages/grafana-data/src/field/fieldDisplay.ts @@ -3,7 +3,7 @@ import { isEmpty } from 'lodash'; import { DataFrameView } from '../dataframe/DataFrameView'; import { getTimeField } from '../dataframe/processDataFrame'; import { GrafanaTheme2 } from '../themes/types'; -import { reduceField, ReducerID } from '../transformations/fieldReducer'; +import { isReducerID, reduceField, ReducerID } from '../transformations/fieldReducer'; import { getFieldMatcher } from '../transformations/matchers'; import { FieldMatcherID } from '../transformations/matchers/ids'; import { ScopedVars } from '../types/ScopedVars'; @@ -43,6 +43,7 @@ export interface FieldSparkline { x?: Field; // if this does not exist, use the index timeRange?: TimeRange; // Optionally force an absolute time highlightIndex?: number; + highlightLine?: number; } export interface FieldDisplay { @@ -72,6 +73,76 @@ export interface GetFieldDisplayValuesOptions { export const DEFAULT_FIELD_DISPLAY_VALUES_LIMIT = 25; +interface SparklineHighlightPoint { + type: 'point'; + xIdx: number; +} + +interface SparklineHighlightLine { + type: 'line'; + y: number; +} + +export function getSparklineHighlight( + sparkline: FieldSparkline, + calc: ReducerID +): SparklineHighlightPoint | SparklineHighlightLine | void { + switch (calc) { + case ReducerID.last: + return { type: 'point', xIdx: sparkline.y.values.length - 1 }; + case ReducerID.first: + return { type: 'point', xIdx: 0 }; + case ReducerID.lastNotNull: { + for (let k = sparkline.y.values.length - 1; k >= 0; k--) { + const v = sparkline.y.values[k]; + if (v !== null && v !== undefined && !Number.isNaN(v)) { + return { type: 'point', xIdx: k }; + } + } + return; + } + case ReducerID.firstNotNull: { + for (let k = 0; k < sparkline.y.values.length; k++) { + const v = sparkline.y.values[k]; + if (v !== null && v !== undefined && !Number.isNaN(v)) { + return { type: 'point', xIdx: k }; + } + } + return; + } + case ReducerID.min: { + let minIdx = -1; + let prevMin = Infinity; + for (let k = 0; k < sparkline.y.values.length; k++) { + const v = sparkline.y.values[k]; + if (v !== null && v !== undefined && !Number.isNaN(v) && v < prevMin) { + prevMin = v; + minIdx = k; + } + } + return minIdx >= 0 ? { type: 'point', xIdx: minIdx } : undefined; + } + case ReducerID.max: { + let maxIdx = -1; + let prevMax = -Infinity; + for (let k = 0; k < sparkline.y.values.length; k++) { + const v = sparkline.y.values[k]; + if (v !== null && v !== undefined && !Number.isNaN(v) && v > prevMax) { + prevMax = v; + maxIdx = k; + } + } + return maxIdx >= 0 ? { type: 'point', xIdx: maxIdx } : undefined; + } + case ReducerID.mean: + return { type: 'line', y: reduceField({ field: sparkline.y, reducers: [ReducerID.mean] }).mean }; + case ReducerID.median: + return { type: 'line', y: reduceField({ field: sparkline.y, reducers: [ReducerID.median] }).median }; + default: + return; + } +} + export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): FieldDisplay[] => { const { replaceVariables, reduceOptions, timeZone, theme } = options; const calcs = reduceOptions.calcs.length ? reduceOptions.calcs : [ReducerID.last]; @@ -190,62 +261,16 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi y: dataFrame.fields[i], x: timeField, }; - let highlightIdx: number | undefined = (() => { - switch (calc) { - case ReducerID.last: - return sparkline.y.values.length - 1; - case ReducerID.first: - return 0; - // TODO: #112977 enable more reducers for highlight index - // case ReducerID.lastNotNull: { - // for (let k = sparkline.y.values.length - 1; k >= 0; k--) { - // const v = sparkline.y.values[k]; - // if (v !== null && v !== undefined && !Number.isNaN(v)) { - // return k; - // } - // } - // return; - // } - // case ReducerID.firstNotNull: { - // for (let k = 0; k < sparkline.y.values.length; k++) { - // const v = sparkline.y.values[k]; - // if (v !== null && v !== undefined && !Number.isNaN(v)) { - // return k; - // } - // } - // return; - // } - // case ReducerID.min: { - // let minIdx = -1; - // let prevMin = Infinity; - // for (let k = 0; k < sparkline.y.values.length; k++) { - // const v = sparkline.y.values[k]; - // if (v !== null && v !== undefined && !Number.isNaN(v) && v < prevMin) { - // prevMin = v; - // minIdx = k; - // } - // } - // return minIdx >= 0 ? minIdx : undefined; - // } - // case ReducerID.max: { - // let maxIdx = -1; - // let prevMax = -Infinity; - // for (let k = 0; k < sparkline.y.values.length; k++) { - // const v = sparkline.y.values[k]; - // if (v !== null && v !== undefined && !Number.isNaN(v) && v > prevMax) { - // prevMax = v; - // maxIdx = k; - // } - // } - // return maxIdx >= 0 ? maxIdx : undefined; - // } - default: - return; + if (isReducerID(calc)) { + const sparklineHighlight = getSparklineHighlight(sparkline, calc); + switch (sparklineHighlight?.type) { + case 'point': + sparkline.highlightIndex = sparklineHighlight.xIdx; + break; + case 'line': + sparkline.highlightLine = sparklineHighlight.y; + break; } - })(); - - if (typeof highlightIdx === 'number') { - sparkline.highlightIndex = highlightIdx; } } diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx index 2d6c45a14bf..4a52d5241d5 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx @@ -67,7 +67,7 @@ export const RadialSparkline = memo( return (
- +
); } diff --git a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx index c18b235e757..d1fb4f3b0e0 100644 --- a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx +++ b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx @@ -14,18 +14,18 @@ export interface SparklineProps extends Themeable2 { height: number; config?: FieldConfig; sparkline: FieldSparkline; + showHighlights?: boolean; } -const SparklineFn: React.FC = memo((props) => { - const { sparkline, config: fieldConfig, theme, width, height } = props; - - const { frame: alignedDataFrame, warning } = prepareSeries(sparkline, fieldConfig); +export const SparklineFn: React.FC = memo((props) => { + const { sparkline, config: fieldConfig, theme, width, height, showHighlights } = props; + const { frame: alignedDataFrame, warning } = prepareSeries(sparkline, theme, fieldConfig, showHighlights); if (warning) { return null; } const data = preparePlotData2(alignedDataFrame, getStackingGroups(alignedDataFrame)); - const configBuilder = prepareConfig(sparkline, alignedDataFrame, theme); + const configBuilder = prepareConfig(sparkline, alignedDataFrame, theme, showHighlights); return ; }); diff --git a/packages/grafana-ui/src/components/Sparkline/utils.test.ts b/packages/grafana-ui/src/components/Sparkline/utils.test.ts index ca49f6da512..0ec65515e0c 100644 --- a/packages/grafana-ui/src/components/Sparkline/utils.test.ts +++ b/packages/grafana-ui/src/components/Sparkline/utils.test.ts @@ -1,6 +1,6 @@ -import { Field, FieldSparkline, FieldType } from '@grafana/data'; +import { createTheme, Field, FieldSparkline, FieldType, toDataFrame } from '@grafana/data'; -import { getYRange, preparePlotFrame } from './utils'; +import { getYRange, prepareConfig, preparePlotFrame } from './utils'; describe('Prepare Sparkline plot frame', () => { it('should return sorted array if x-axis numeric', () => { @@ -201,3 +201,134 @@ describe('Get y range', () => { expect(actual[0]).toBeLessThan(actual[1]!); }); }); + +describe('prepareConfig', () => { + it('should not throw an error if there are multiple values', () => { + const sparkline: FieldSparkline = { + x: { + name: 'x', + values: [1679839200000, 1680444000000, 1681048800000, 1681653600000, 1682258400000], + type: FieldType.time, + config: {}, + }, + y: { + name: 'y', + values: [1, 2, 3, 4, 5], + type: FieldType.number, + config: {}, + }, + }; + + const dataFrame = toDataFrame({ + fields: [sparkline.x, sparkline.y], + }); + + const config = prepareConfig(sparkline, dataFrame, createTheme()); + expect(config.series.length).toBe(1); + }); + + it('should not throw an error if there is a single value', () => { + const sparkline: FieldSparkline = { + x: { + name: 'x', + values: [1679839200000], + type: FieldType.time, + config: {}, + }, + y: { + name: 'y', + values: [1], + type: FieldType.number, + config: {}, + }, + }; + + const dataFrame = toDataFrame({ + fields: [sparkline.x, sparkline.y], + }); + + const config = prepareConfig(sparkline, dataFrame, createTheme()); + expect(config.series.length).toBe(1); + }); + + it('should not throw an error if there are no values', () => { + const sparkline: FieldSparkline = { + x: { + name: 'x', + values: [], + type: FieldType.time, + config: {}, + }, + y: { + name: 'y', + values: [], + type: FieldType.number, + config: {}, + }, + }; + + const dataFrame = toDataFrame({ + fields: [sparkline.x, sparkline.y], + }); + + const config = prepareConfig(sparkline, dataFrame, createTheme()); + expect(config.series.length).toBe(1); + }); + + it('should set up highlight series if showHighlights is true and highlightIdx exists', () => { + const sparkline: FieldSparkline = { + x: { + name: 'x', + values: [1679839200000, 1680444000000, 1681048800000, 1681653600000, 1682258400000], + type: FieldType.time, + config: {}, + }, + y: { + name: 'y', + values: [1, 2, 3, 4, 5], + type: FieldType.number, + config: {}, + }, + highlightIndex: 2, + }; + + const dataFrame = toDataFrame({ + fields: [sparkline.x, sparkline.y], + }); + + const config = prepareConfig(sparkline, dataFrame, createTheme(), true); + expect(config.series.length).toBe(1); + expect(config.series[0].getConfig().points).toEqual( + expect.objectContaining({ + show: true, + filter: [2], + }) + ); + }); + + it('should not set up highlight series if showHighlights is false even if highlightIdx exists', () => { + const sparkline: FieldSparkline = { + x: { + name: 'x', + values: [1679839200000, 1680444000000, 1681048800000, 1681653600000, 1682258400000], + type: FieldType.time, + config: {}, + }, + y: { + name: 'y', + values: [1, 2, 3, 4, 5], + type: FieldType.number, + config: {}, + }, + highlightIndex: 2, + }; + + const dataFrame = toDataFrame({ + fields: [sparkline.x, sparkline.y], + }); + + const config = prepareConfig(sparkline, dataFrame, createTheme(), false); + expect(config.series.length).toBe(1); + expect(config.series[0].getConfig().points?.show).not.toBe(true); + }); +}); diff --git a/packages/grafana-ui/src/components/Sparkline/utils.ts b/packages/grafana-ui/src/components/Sparkline/utils.ts index be24eb6c4e8..c1402c4da2d 100644 --- a/packages/grafana-ui/src/components/Sparkline/utils.ts +++ b/packages/grafana-ui/src/components/Sparkline/utils.ts @@ -2,6 +2,7 @@ import { Range } from 'uplot'; import { applyNullInsertThreshold, + // colorManipulator, DataFrame, FieldConfig, FieldSparkline, @@ -22,6 +23,7 @@ import { VisibilityMode, ScaleDirection, ScaleOrientation, + // FieldColorModeId, } from '@grafana/schema'; import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder'; @@ -112,8 +114,7 @@ export function getYRange(alignedFrame: DataFrame): Range.MinMax { return [roundedMin, roundedMax]; } -// TODO: #112977 enable highlight index -// const HIGHLIGHT_IDX_POINT_SIZE = 6; +const HIGHLIGHT_IDX_POINT_SIZE = 6; const defaultConfig: GraphFieldConfig = { drawStyle: GraphDrawStyle.Line, @@ -124,7 +125,9 @@ const defaultConfig: GraphFieldConfig = { export const prepareSeries = ( sparkline: FieldSparkline, - fieldConfig?: FieldConfig + _theme: GrafanaTheme2, + fieldConfig?: FieldConfig, + _showHighlights?: boolean ): { frame: DataFrame; warning?: string } => { const frame = nullToValue(preparePlotFrame(sparkline, fieldConfig)); if (frame.fields.some((f) => f.values.length <= 1)) { @@ -136,16 +139,41 @@ export const prepareSeries = ( frame, }; } + // TODO:rgb(24, 24, 24) will address this. + // if (showHighlights && typeof sparkline.highlightLine === 'number') { + // const highlightY = sparkline.highlightLine; + // const colorMode = getFieldColorModeForField(sparkline.y); + // const seriesColor = colorMode.getCalculator(sparkline.y, theme)(highlightY, 0); + // frame.fields.push({ + // name: 'highlightLine', + // type: FieldType.number, + // values: new Array(frame.length).fill(highlightY), + // config: { + // color: { + // mode: FieldColorModeId.Fixed, + // fixedColor: colorManipulator.lighten(seriesColor, 0.5), + // }, + // custom: { + // lineStyle: { + // fill: 'dash', + // dash: [5, 2], + // }, + // }, + // }, + // state: {}, + // }); + // } return { frame }; }; export const prepareConfig = ( sparkline: FieldSparkline, dataFrame: DataFrame, - theme: GrafanaTheme2 + theme: GrafanaTheme2, + showHighlights?: boolean ): UPlotConfigBuilder => { const builder = new UPlotConfigBuilder(); - // const rangePad = HIGHLIGHT_IDX_POINT_SIZE / 2; + const rangePad = HIGHLIGHT_IDX_POINT_SIZE / 2; builder.setCursor({ show: false, @@ -206,13 +234,14 @@ export const prepareConfig = ( const colorMode = getFieldColorModeForField(field); const seriesColor = colorMode.getCalculator(field, theme)(0, 0); - // TODO: #112977 enable highlight index and adjust padding accordingly - // const hasHighlightIndex = typeof sparkline.highlightIndex === 'number'; - // if (hasHighlightIndex) { - // builder.setPadding([rangePad, rangePad, rangePad, rangePad]); - // } + + const hasHighlightIndex = showHighlights && typeof sparkline.highlightIndex === 'number'; + if (hasHighlightIndex) { + builder.setPadding([rangePad, rangePad, rangePad, rangePad]); + } + const pointsMode = - customConfig.drawStyle === GraphDrawStyle.Points // || hasHighlightIndex + customConfig.drawStyle === GraphDrawStyle.Points || hasHighlightIndex ? VisibilityMode.Always : customConfig.showPoints; @@ -227,9 +256,8 @@ export const prepareConfig = ( lineWidth: customConfig.lineWidth, lineInterpolation: customConfig.lineInterpolation, showPoints: pointsMode, - // TODO: #112977 enable highlight index - pointSize: /* hasHighlightIndex ? HIGHLIGHT_IDX_POINT_SIZE : */ customConfig.pointSize, - // pointsFilter: hasHighlightIndex ? [sparkline.highlightIndex!] : undefined, + pointSize: hasHighlightIndex ? HIGHLIGHT_IDX_POINT_SIZE : customConfig.pointSize, + pointsFilter: hasHighlightIndex ? [sparkline.highlightIndex!] : undefined, fillOpacity: customConfig.fillOpacity, fillColor: customConfig.fillColor, lineStyle: customConfig.lineStyle, From 5e4e6c1172826351bd7c9aa689679d2b87bf61f2 Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Wed, 24 Dec 2025 00:42:01 +0000 Subject: [PATCH 083/229] I18n: Download translations from Crowdin (#115705) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- public/locales/cs-CZ/grafana.json | 2 ++ public/locales/de-DE/grafana.json | 2 ++ public/locales/es-ES/grafana.json | 2 ++ public/locales/fr-FR/grafana.json | 2 ++ public/locales/hu-HU/grafana.json | 2 ++ public/locales/id-ID/grafana.json | 2 ++ public/locales/it-IT/grafana.json | 2 ++ public/locales/ja-JP/grafana.json | 2 ++ public/locales/ko-KR/grafana.json | 2 ++ public/locales/nl-NL/grafana.json | 2 ++ public/locales/pl-PL/grafana.json | 2 ++ public/locales/pt-BR/grafana.json | 2 ++ public/locales/pt-PT/grafana.json | 2 ++ public/locales/ru-RU/grafana.json | 2 ++ public/locales/sv-SE/grafana.json | 2 ++ public/locales/tr-TR/grafana.json | 2 ++ public/locales/zh-Hans/grafana.json | 2 ++ public/locales/zh-Hant/grafana.json | 2 ++ 18 files changed, 36 insertions(+) diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index 84cc597980b..2fb06738b36 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -3780,6 +3780,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index ef5f649123d..6c09564ae2f 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -3748,6 +3748,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 443dbefbc39..45d955ba66c 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -3748,6 +3748,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 1d98e007593..33bf748fbc0 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -3748,6 +3748,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index bfe9d3e9542..3704f01de9a 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -3748,6 +3748,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index 86b18767abb..602acd8813a 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -3732,6 +3732,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index 976d81b2f37..4832c6c744c 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -3748,6 +3748,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index 9bfbc78a21e..c87617b2161 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -3732,6 +3732,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index bd4103a1de1..65d967807ea 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -3732,6 +3732,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index d5386284647..a1d9ba17c5b 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -3748,6 +3748,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index ad8f9b19b6a..c7d04e0cd8b 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -3780,6 +3780,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index 8ad480fc30d..eee46fc8344 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -3748,6 +3748,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index 3fed004bfdf..415075e65ab 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -3748,6 +3748,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index 10cd0cdd7bb..a8aab23f3d0 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -3780,6 +3780,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index faca6a40afc..f3c4effc8b3 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -3748,6 +3748,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index ad957bd271f..7cd8b7b5939 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -3748,6 +3748,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index f02bcda5189..b36e525f676 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -3732,6 +3732,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index dc05987a2e6..0302a7ffb6f 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -3732,6 +3732,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { From 3f5f0f783b5219243d04cad1de20e58bb4533b47 Mon Sep 17 00:00:00 2001 From: "alerting-team[bot]" <158350966+alerting-team[bot]@users.noreply.github.com> Date: Wed, 24 Dec 2025 08:48:06 +0100 Subject: [PATCH 084/229] Alerting: Update alerting module to 926c7491019668286c423cad9d2a65f419b14944 (#115704) [create-pull-request] automated change Co-authored-by: alexander-akhmetov <1875873+alexander-akhmetov@users.noreply.github.com> --- apps/advisor/go.mod | 2 +- apps/advisor/go.sum | 4 ++-- apps/alerting/historian/go.mod | 2 +- apps/alerting/historian/go.sum | 4 ++-- apps/iam/go.mod | 2 +- apps/iam/go.sum | 4 ++-- apps/plugins/go.mod | 2 +- apps/plugins/go.sum | 4 ++-- go.mod | 2 +- go.sum | 4 ++-- 10 files changed, 15 insertions(+), 15 deletions(-) diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod index efc9ed4d500..646ceed9a86 100644 --- a/apps/advisor/go.mod +++ b/apps/advisor/go.mod @@ -157,7 +157,7 @@ require ( github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/google/wire v0.7.0 // indirect - github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 // indirect + github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // indirect github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect diff --git a/apps/advisor/go.sum b/apps/advisor/go.sum index 07730457d60..750d9f97fc5 100644 --- a/apps/advisor/go.sum +++ b/apps/advisor/go.sum @@ -619,8 +619,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 h1:m5VqerdocNDh1vTi8itJmX9DwonGqj+SfkO0JxPHt0E= -github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= +github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= diff --git a/apps/alerting/historian/go.mod b/apps/alerting/historian/go.mod index 9a83b79c0f6..fb624d65db3 100644 --- a/apps/alerting/historian/go.mod +++ b/apps/alerting/historian/go.mod @@ -4,7 +4,7 @@ go 1.25.5 require ( github.com/go-kit/log v0.2.1 - github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 + github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 github.com/grafana/grafana-app-sdk v0.48.7 github.com/grafana/grafana-app-sdk/logging v0.48.7 diff --git a/apps/alerting/historian/go.sum b/apps/alerting/historian/go.sum index 17beef468f0..0835100976a 100644 --- a/apps/alerting/historian/go.sum +++ b/apps/alerting/historian/go.sum @@ -243,8 +243,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 h1:m5VqerdocNDh1vTi8itJmX9DwonGqj+SfkO0JxPHt0E= -github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= +github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4= github.com/grafana/grafana-app-sdk v0.48.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfUHB32u2ZMo= diff --git a/apps/iam/go.mod b/apps/iam/go.mod index 8a6cec152cd..54689bc54f3 100644 --- a/apps/iam/go.mod +++ b/apps/iam/go.mod @@ -223,7 +223,7 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/gorilla/mux v1.8.1 // indirect - github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 // indirect + github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // indirect github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect diff --git a/apps/iam/go.sum b/apps/iam/go.sum index 00d85d14de4..28bf1486774 100644 --- a/apps/iam/go.sum +++ b/apps/iam/go.sum @@ -827,8 +827,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 h1:m5VqerdocNDh1vTi8itJmX9DwonGqj+SfkO0JxPHt0E= -github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= +github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= diff --git a/apps/plugins/go.mod b/apps/plugins/go.mod index 62f8f4edf0f..a2657edda7a 100644 --- a/apps/plugins/go.mod +++ b/apps/plugins/go.mod @@ -90,7 +90,7 @@ require ( github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 // indirect + github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // indirect github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect diff --git a/apps/plugins/go.sum b/apps/plugins/go.sum index f2dbfce834a..f0c923083af 100644 --- a/apps/plugins/go.sum +++ b/apps/plugins/go.sum @@ -213,8 +213,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 h1:m5VqerdocNDh1vTi8itJmX9DwonGqj+SfkO0JxPHt0E= -github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= +github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= diff --git a/go.mod b/go.mod index 492087be19f..f22d410c51f 100644 --- a/go.mod +++ b/go.mod @@ -87,7 +87,7 @@ require ( github.com/googleapis/gax-go/v2 v2.15.0 // @grafana/grafana-backend-group github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // @grafana/grafana-app-platform-squad - github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 // @grafana/alerting-backend + github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // @grafana/alerting-backend github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // @grafana/identity-access-team github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // @grafana/identity-access-team github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics diff --git a/go.sum b/go.sum index 9d7d7380b71..069d53dd5e9 100644 --- a/go.sum +++ b/go.sum @@ -1622,8 +1622,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 h1:m5VqerdocNDh1vTi8itJmX9DwonGqj+SfkO0JxPHt0E= -github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= +github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= From 4f57ebe4ad636cbfc50b220bf0ce18ea6b4ac18d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20Sencer=20=C3=96zcan?= <32759850+mustafasencer@users.noreply.github.com> Date: Wed, 24 Dec 2025 09:33:24 +0100 Subject: [PATCH 085/229] fix: bump default facet search limit for unified search (#115690) * fix: bump limit * feat: add facetLimit query parameter to search API * fix: set to 500 * fix: update snapshot * fix: yarn generate-apis --- .../rtkq/dashboard/v0alpha1/endpoints.gen.ts | 3 ++ pkg/registry/apis/dashboard/search.go | 20 +++++++++++- pkg/registry/apis/dashboard/search_test.go | 32 +++++++++++++++++++ .../dashboard.grafana.app-v0alpha1.json | 9 ++++++ public/app/features/search/service/unified.ts | 2 +- 5 files changed, 64 insertions(+), 2 deletions(-) diff --git a/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts index 5d3e72b13aa..b50a074e4a2 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts @@ -243,6 +243,7 @@ const injectedRtkApi = api type: queryArg['type'], folder: queryArg.folder, facet: queryArg.facet, + facetLimit: queryArg.facetLimit, tags: queryArg.tags, libraryPanel: queryArg.libraryPanel, permission: queryArg.permission, @@ -663,6 +664,8 @@ export type SearchDashboardsAndFoldersApiArg = { folder?: string; /** count distinct terms for selected fields */ facet?: string[]; + /** maximum number of terms to return per facet (default 50, max 1000) */ + facetLimit?: number; /** tag query filter */ tags?: string[]; /** find dashboards that reference a given libraryPanel */ diff --git a/pkg/registry/apis/dashboard/search.go b/pkg/registry/apis/dashboard/search.go index e28eeedcecc..08a943b8da6 100644 --- a/pkg/registry/apis/dashboard/search.go +++ b/pkg/registry/apis/dashboard/search.go @@ -115,6 +115,15 @@ func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) * Schema: spec.ArrayProperty(spec.StringProperty()), }, }, + { + ParameterProps: spec3.ParameterProps{ + Name: "facetLimit", + In: "query", + Description: "maximum number of terms to return per facet (default 50, max 1000)", + Required: false, + Schema: spec.Int64Property(), + }, + }, { ParameterProps: spec3.ParameterProps{ Name: "tags", @@ -340,6 +349,7 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) { func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, user identity.Requester, getDashboardsUIDsSharedWithUser func() ([]string, error)) (*resourcepb.ResourceSearchRequest, error) { // get limit and offset from query params limit := 50 + facetLimit := 50 offset := 0 page := 1 if queryParams.Has("limit") { @@ -422,11 +432,19 @@ func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, use // The facet term fields if facets, ok := queryParams["facet"]; ok { + if queryParams.Has("facetLimit") { + if parsed, err := strconv.Atoi(queryParams.Get("facetLimit")); err == nil && parsed > 0 { + facetLimit = parsed + if facetLimit > 1000 { + facetLimit = 1000 + } + } + } searchRequest.Facet = make(map[string]*resourcepb.ResourceSearchRequest_Facet) for _, v := range facets { searchRequest.Facet[v] = &resourcepb.ResourceSearchRequest_Facet{ Field: v, - Limit: 50, + Limit: int64(facetLimit), } } } diff --git a/pkg/registry/apis/dashboard/search_test.go b/pkg/registry/apis/dashboard/search_test.go index 406494b9d36..3b9935f8247 100644 --- a/pkg/registry/apis/dashboard/search_test.go +++ b/pkg/registry/apis/dashboard/search_test.go @@ -818,6 +818,38 @@ func TestConvertHttpSearchRequestToResourceSearchRequest(t *testing.T) { Federated: []*resourcepb.ResourceKey{folderKey}, }, }, + "facet fields with custom limit": { + queryString: "facet=tags&facetLimit=500", + expected: &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{Key: dashboardKey}, + Query: "", + Limit: 50, + Offset: 0, + Page: 1, + Explain: false, + Fields: defaultFields, + Facet: map[string]*resourcepb.ResourceSearchRequest_Facet{ + "tags": {Field: "tags", Limit: 500}, + }, + Federated: []*resourcepb.ResourceKey{folderKey}, + }, + }, + "facet fields with limit exceeding max": { + queryString: "facet=tags&facetLimit=5000", + expected: &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{Key: dashboardKey}, + Query: "", + Limit: 50, + Offset: 0, + Page: 1, + Explain: false, + Fields: defaultFields, + Facet: map[string]*resourcepb.ResourceSearchRequest_Facet{ + "tags": {Field: "tags", Limit: 1000}, + }, + Federated: []*resourcepb.ResourceKey{folderKey}, + }, + }, "tag filter": { queryString: "tag=tag1&tag=tag2", expected: &resourcepb.ResourceSearchRequest{ diff --git a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json index b65fa2ad0d7..61834093866 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json @@ -1802,6 +1802,15 @@ } } }, + { + "name": "facetLimit", + "in": "query", + "description": "maximum number of terms to return per facet (default 50, max 1000)", + "schema": { + "type": "integer", + "format": "int64" + } + }, { "name": "tags", "in": "query", diff --git a/public/app/features/search/service/unified.ts b/public/app/features/search/service/unified.ts index 8d1af58f9d9..146a54d295d 100644 --- a/public/app/features/search/service/unified.ts +++ b/public/app/features/search/service/unified.ts @@ -106,7 +106,7 @@ export class UnifiedSearcher implements GrafanaSearcher { async tags(query: SearchQuery): Promise { const qry = query.query ?? '*'; - let uri = `${searchURI}?facet=tags&query=${qry}&limit=1`; + let uri = `${searchURI}?facet=tags&facetLimit=1000&query=${qry}&limit=1`; const resp = await getBackendSrv().get(uri); return resp.facets?.tags?.terms || []; } From c38e515dec1608c5072c6684a80e1f9ef96dc064 Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Wed, 24 Dec 2025 09:49:38 +0100 Subject: [PATCH 086/229] Alerting: Fix export of imported Prometheus-style recording rules to terraform (#115661) Alerting: Fix export imported Prometheus-style recording rules to terraform --- .../ngalert/api/api_ruler_validation_test.go | 2 + pkg/services/ngalert/api/compat/compat.go | 84 +++++++++------- .../ngalert/api/compat/compat_test.go | 97 +++++++++++++++++++ .../api/validation/api_ruler_validation.go | 1 + pkg/services/ngalert/prom/convert.go | 10 +- pkg/services/ngalert/prom/convert_test.go | 7 +- 6 files changed, 158 insertions(+), 43 deletions(-) diff --git a/pkg/services/ngalert/api/api_ruler_validation_test.go b/pkg/services/ngalert/api/api_ruler_validation_test.go index 98fbb20cf12..553a02cff30 100644 --- a/pkg/services/ngalert/api/api_ruler_validation_test.go +++ b/pkg/services/ngalert/api/api_ruler_validation_test.go @@ -493,6 +493,7 @@ func TestValidateRuleNode_NoUID(t *testing.T) { r.GrafanaManagedAlert.NoDataState = apimodels.OK r.GrafanaManagedAlert.ExecErrState = apimodels.AlertingErrState r.GrafanaManagedAlert.NotificationSettings = &apimodels.AlertRuleNotificationSettings{} + r.GrafanaManagedAlert.MissingSeriesEvalsToResolve = util.Pointer[int64](1) r.For = func() *model.Duration { five := model.Duration(time.Second * 5); return &five }() r.KeepFiringFor = func() *model.Duration { five := model.Duration(time.Second * 5); return &five }() return &r @@ -502,6 +503,7 @@ func TestValidateRuleNode_NoUID(t *testing.T) { require.Empty(t, alert.NoDataState) require.Empty(t, alert.ExecErrState) require.Nil(t, alert.NotificationSettings) + require.Nil(t, alert.MissingSeriesEvalsToResolve) require.Zero(t, alert.For) require.Zero(t, alert.KeepFiringFor) }, diff --git a/pkg/services/ngalert/api/compat/compat.go b/pkg/services/ngalert/api/compat/compat.go index 5fda13672ba..37c9a8db6ed 100644 --- a/pkg/services/ngalert/api/compat/compat.go +++ b/pkg/services/ngalert/api/compat/compat.go @@ -189,42 +189,11 @@ func AlertRuleExportFromAlertRule(rule models.AlertRule) (definitions.AlertRuleE data = append(data, query) } - cPtr := &rule.Condition - if rule.Condition == "" { - cPtr = nil - } - - noDataState := definitions.NoDataState(rule.NoDataState) - ndsPtr := &noDataState - if noDataState == "" { - ndsPtr = nil - } - execErrorState := definitions.ExecutionErrorState(rule.ExecErrState) - eesPtr := &execErrorState - if execErrorState == "" { - eesPtr = nil - } - result := definitions.AlertRuleExport{ - UID: rule.UID, - Title: rule.Title, - For: model.Duration(rule.For), - KeepFiringFor: model.Duration(rule.KeepFiringFor), - Condition: cPtr, - Data: data, - DashboardUID: rule.DashboardUID, - PanelID: rule.PanelID, - NoDataState: ndsPtr, - ExecErrState: eesPtr, - IsPaused: rule.IsPaused, - NotificationSettings: AlertRuleNotificationSettingsExportFromNotificationSettings(rule.NotificationSettings), - Record: AlertRuleRecordExportFromRecord(rule.Record), - } - if rule.For.Seconds() > 0 { - result.ForString = util.Pointer(model.Duration(rule.For).String()) - } - if rule.KeepFiringFor.Seconds() > 0 { - result.KeepFiringForString = util.Pointer(model.Duration(rule.KeepFiringFor).String()) + UID: rule.UID, + Title: rule.Title, + Data: data, + IsPaused: rule.IsPaused, } if rule.Annotations != nil { result.Annotations = &rule.Annotations @@ -232,13 +201,54 @@ func AlertRuleExportFromAlertRule(rule models.AlertRule) (definitions.AlertRuleE if rule.Labels != nil { result.Labels = &rule.Labels } - if rule.MissingSeriesEvalsToResolve != nil && *rule.MissingSeriesEvalsToResolve != -1 { - result.MissingSeriesEvalsToResolve = rule.MissingSeriesEvalsToResolve + + if rule.Type() == models.RuleTypeRecording { + populateRecordingRuleExportFields(rule, &result) + } else { + populateAlertingRuleExportFields(rule, &result) } return result, nil } +func populateRecordingRuleExportFields(rule models.AlertRule, result *definitions.AlertRuleExport) { + result.Record = AlertRuleRecordExportFromRecord(rule.Record) +} + +func populateAlertingRuleExportFields(rule models.AlertRule, result *definitions.AlertRuleExport) { + result.DashboardUID = rule.DashboardUID + result.PanelID = rule.PanelID + result.NotificationSettings = AlertRuleNotificationSettingsExportFromNotificationSettings(rule.NotificationSettings) + + if rule.Condition != "" { + result.Condition = &rule.Condition + } + + if rule.NoDataState != "" { + noDataState := definitions.NoDataState(rule.NoDataState) + result.NoDataState = &noDataState + } + + if rule.ExecErrState != "" { + execErrorState := definitions.ExecutionErrorState(rule.ExecErrState) + result.ExecErrState = &execErrorState + } + + result.For = model.Duration(rule.For) + if rule.For > 0 { + result.ForString = util.Pointer(model.Duration(rule.For).String()) + } + + result.KeepFiringFor = model.Duration(rule.KeepFiringFor) + if rule.KeepFiringFor > 0 { + result.KeepFiringForString = util.Pointer(model.Duration(rule.KeepFiringFor).String()) + } + + if rule.MissingSeriesEvalsToResolve != nil && *rule.MissingSeriesEvalsToResolve != -1 { + result.MissingSeriesEvalsToResolve = rule.MissingSeriesEvalsToResolve + } +} + func encodeQueryModel(m map[string]any) (string, error) { var buf bytes.Buffer enc := json.NewEncoder(&buf) diff --git a/pkg/services/ngalert/api/compat/compat_test.go b/pkg/services/ngalert/api/compat/compat_test.go index 4a107335945..8b50c609559 100644 --- a/pkg/services/ngalert/api/compat/compat_test.go +++ b/pkg/services/ngalert/api/compat/compat_test.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/util" ) func TestToModel(t *testing.T) { @@ -115,6 +116,102 @@ func TestToModel(t *testing.T) { }) } +func TestAlertRuleExportFromAlertRule(t *testing.T) { + alertingRule := models.RuleGen.With( + models.RuleGen.WithNotEmptyLabels(2, "lbl-"), + models.RuleGen.WithAnnotations(map[string]string{"ann-key": "ann-value"}), + models.RuleGen.WithFor(2*time.Minute), + models.RuleGen.WithKeepFiringFor(5*time.Minute), + models.RuleGen.WithNotificationSettingsGen(models.NotificationSettingsGen()), + ).Generate() + recordingRule := models.RuleGen.With( + models.RuleGen.WithAllRecordingRules(), + models.RuleGen.WithNotEmptyLabels(2, "lbl-"), + models.RuleGen.WithAnnotations(map[string]string{"ann-key": "ann-value"}), + ).Generate() + + // Build expected exported recording rule + recordingRuleData, err := AlertQueryExportFromAlertQuery(recordingRule.Data[0]) + require.NoError(t, err) + expectedRecordingRuleExport := definitions.AlertRuleExport{ + UID: recordingRule.UID, + Title: recordingRule.Title, + Data: []definitions.AlertQueryExport{recordingRuleData}, + Annotations: &recordingRule.Annotations, + Labels: &recordingRule.Labels, + Record: &definitions.AlertRuleRecordExport{ + Metric: recordingRule.Record.Metric, + From: recordingRule.Record.From, + TargetDatasourceUID: util.Pointer(recordingRule.Record.TargetDatasourceUID), + }, + } + + // Build expected exported alerting rule + alertingRuleData, err := AlertQueryExportFromAlertQuery(alertingRule.Data[0]) + require.NoError(t, err) + noDataState := definitions.NoDataState(alertingRule.NoDataState) + execErrState := definitions.ExecutionErrorState(alertingRule.ExecErrState) + expectedAlertingRuleExport := definitions.AlertRuleExport{ + UID: alertingRule.UID, + Title: alertingRule.Title, + Condition: &alertingRule.Condition, + Data: []definitions.AlertQueryExport{alertingRuleData}, + DashboardUID: alertingRule.DashboardUID, + PanelID: alertingRule.PanelID, + NoDataState: &noDataState, + ExecErrState: &execErrState, + For: prommodel.Duration(alertingRule.For), + KeepFiringFor: prommodel.Duration(alertingRule.KeepFiringFor), + ForString: util.Pointer(prommodel.Duration(alertingRule.For).String()), + KeepFiringForString: util.Pointer(prommodel.Duration(alertingRule.KeepFiringFor).String()), + Annotations: &alertingRule.Annotations, + Labels: &alertingRule.Labels, + NotificationSettings: AlertRuleNotificationSettingsExportFromNotificationSettings(alertingRule.NotificationSettings), + MissingSeriesEvalsToResolve: alertingRule.MissingSeriesEvalsToResolve, + } + + testCases := []struct { + name string + rule models.AlertRule + expected definitions.AlertRuleExport + }{ + { + name: "export recording rule", + rule: recordingRule, + expected: expectedRecordingRuleExport, + }, + { + name: "export alerting rule", + rule: alertingRule, + expected: expectedAlertingRuleExport, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + exported, err := AlertRuleExportFromAlertRule(tc.rule) + require.NoError(t, err) + require.Equal(t, tc.expected, exported) + }) + } +} + +func TestAlertQueryExportFromAlertQuery(t *testing.T) { + query := models.RuleGen.GenerateQuery() + + exported, err := AlertQueryExportFromAlertQuery(query) + require.NoError(t, err) + + require.Equal(t, query.RefID, exported.RefID) + require.Equal(t, query.DatasourceUID, exported.DatasourceUID) + require.Equal(t, int64(time.Duration(query.RelativeTimeRange.From).Seconds()), exported.RelativeTimeRange.FromSeconds) + require.Equal(t, int64(time.Duration(query.RelativeTimeRange.To).Seconds()), exported.RelativeTimeRange.ToSeconds) + require.NotNil(t, exported.QueryType) + require.Equal(t, query.QueryType, *exported.QueryType) + require.NotNil(t, exported.Model) + require.NotEmpty(t, exported.ModelString) +} + func TestAlertRuleMetadataFromModelMetadata(t *testing.T) { t.Run("should convert model metadata to api metadata", func(t *testing.T) { modelMetadata := models.AlertRuleMetadata{ diff --git a/pkg/services/ngalert/api/validation/api_ruler_validation.go b/pkg/services/ngalert/api/validation/api_ruler_validation.go index c2baf8108ba..5a74c58f90e 100644 --- a/pkg/services/ngalert/api/validation/api_ruler_validation.go +++ b/pkg/services/ngalert/api/validation/api_ruler_validation.go @@ -193,6 +193,7 @@ func validateRecordingRuleFields(in *apimodels.PostableExtendedRuleNode, newRule newRule.For = 0 newRule.KeepFiringFor = 0 newRule.NotificationSettings = nil + newRule.MissingSeriesEvalsToResolve = nil return newRule, nil } diff --git a/pkg/services/ngalert/prom/convert.go b/pkg/services/ngalert/prom/convert.go index 13c95e70aa4..0e8ebf647d4 100644 --- a/pkg/services/ngalert/prom/convert.go +++ b/pkg/services/ngalert/prom/convert.go @@ -272,16 +272,16 @@ func (p *Converter) convertRule(orgID int64, namespaceUID string, promGroup Prom RuleGroup: promGroup.Name, IsPaused: isPaused, Record: record, + } + + if !isRecordingRule { + result.NotificationSettings = p.cfg.NotificationSettings // MissingSeriesEvalsToResolve is set to 1 to match the Prometheus behaviour. // Prometheus resolves alerts as soon as the series disappears. // By setting this value to 1 we ensure that the alert is resolved on the first evaluation // that doesn't have the series. - MissingSeriesEvalsToResolve: util.Pointer[int64](1), - } - - if !isRecordingRule { - result.NotificationSettings = p.cfg.NotificationSettings + result.MissingSeriesEvalsToResolve = util.Pointer[int64](1) } if p.cfg.KeepOriginalRuleDefinition != nil && *p.cfg.KeepOriginalRuleDefinition { diff --git a/pkg/services/ngalert/prom/convert_test.go b/pkg/services/ngalert/prom/convert_test.go index 503cd76dd64..d9542e24c5e 100644 --- a/pkg/services/ngalert/prom/convert_test.go +++ b/pkg/services/ngalert/prom/convert_test.go @@ -358,7 +358,12 @@ func TestPrometheusRulesToGrafana(t *testing.T) { require.Equal(t, models.Duration(evalOffset), grafanaRule.Data[0].RelativeTimeRange.To) require.Equal(t, models.Duration(10*time.Minute+evalOffset), grafanaRule.Data[0].RelativeTimeRange.From) - require.Equal(t, util.Pointer(int64(1)), grafanaRule.MissingSeriesEvalsToResolve) + + if promRule.Record != "" { + require.Nil(t, grafanaRule.MissingSeriesEvalsToResolve) + } else { + require.Equal(t, util.Pointer(int64(1)), grafanaRule.MissingSeriesEvalsToResolve) + } require.Equal(t, models.OkErrState, grafanaRule.ExecErrState) require.Equal(t, models.OK, grafanaRule.NoDataState) From e38f007d305fc73beb4ad7c66697cd71a42a6071 Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Wed, 24 Dec 2025 13:41:46 +0100 Subject: [PATCH 087/229] Alerting: Fetch alert rule provenances for a page of rules only (#115643) * Alerting: Fetch alert rule provenances for a page of rules only * error when failed to fetch provenance --- .../ngalert/api/api_prometheus_test.go | 134 ++++++++++++++++++ .../ngalert/api/prometheus/api_prometheus.go | 48 +++++-- .../ngalert/notifier/alertmanager_config.go | 1 + pkg/services/ngalert/provisioning/persist.go | 1 + .../provisioning/provisioning_store_mock.go | 61 ++++++++ .../ngalert/store/provisioning_store.go | 24 ++++ .../ngalert/store/provisioning_store_test.go | 49 +++++++ .../ngalert/tests/fakes/provisioning.go | 30 +++- 8 files changed, 331 insertions(+), 17 deletions(-) diff --git a/pkg/services/ngalert/api/api_prometheus_test.go b/pkg/services/ngalert/api/api_prometheus_test.go index 75ec6c901fd..dc0f6d1f13d 100644 --- a/pkg/services/ngalert/api/api_prometheus_test.go +++ b/pkg/services/ngalert/api/api_prometheus_test.go @@ -2369,6 +2369,140 @@ func TestRouteGetRuleStatuses(t *testing.T) { } }) + t.Run("multi-page pagination loads provenance correctly", func(t *testing.T) { + fakeStore, fakeAIM, api, fakeProvisioning := setupAPIFull(t) + + // Create 3 groups with 1 rule each: groups 1 and 3 firing, group 2 normal + for i := 1; i <= 3; i++ { + rule := gen.With(gen.WithOrgID(orgID), func(r *ngmodels.AlertRule) { + r.NamespaceUID = "ns-1" + r.RuleGroup = fmt.Sprintf("group-%d", i) + r.UID = fmt.Sprintf("rule-%d", i) + }, withClassicConditionSingleQuery()).GenerateRef() + + alertState := eval.Normal + if i != 2 { + alertState = eval.Alerting + } + fakeAIM.GenerateAlertInstances(orgID, rule.UID, 1, func(s *state.State) *state.State { + s.State = alertState + s.Labels = data.Labels{"test": "label"} + return s + }) + fakeStore.PutRule(context.Background(), rule) + } + + // Set provenance for all rules + err := fakeProvisioning.SetProvenance(context.Background(), + &ngmodels.AlertRule{UID: "rule-1", OrgID: orgID}, orgID, ngmodels.ProvenanceAPI) + require.NoError(t, err) + err = fakeProvisioning.SetProvenance(context.Background(), + &ngmodels.AlertRule{UID: "rule-3", OrgID: orgID}, orgID, ngmodels.ProvenanceFile) + require.NoError(t, err) + + // Request firing groups with group_limit=2 - fetches multiple pages, skipping group 2 + req, err := http.NewRequest("GET", "/api/v1/rules?state=firing&group_limit=2", nil) + require.NoError(t, err) + c := &contextmodel.ReqContext{ + Context: &web.Context{Req: req}, + SignedInUser: &user.SignedInUser{ + OrgID: orgID, + Permissions: queryPermissions, + }, + } + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + + var res apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res)) + + // Should return 2 firing groups + require.Len(t, res.Data.RuleGroups, 2) + require.Equal(t, "group-1", res.Data.RuleGroups[0].Name) + require.Equal(t, apimodels.Provenance(ngmodels.ProvenanceAPI), res.Data.RuleGroups[0].Rules[0].Provenance) + require.Equal(t, "group-3", res.Data.RuleGroups[1].Name) + require.Equal(t, apimodels.Provenance(ngmodels.ProvenanceFile), res.Data.RuleGroups[1].Rules[0].Provenance) + }) + + t.Run("provenance fetch error returns error response in paginated mode", func(t *testing.T) { + fakeStore, fakeAIM, api, fakeProvisioning := setupAPIFull(t) + + rule := gen.With(gen.WithOrgID(orgID), func(r *ngmodels.AlertRule) { + r.NamespaceUID = "ns-1" + r.RuleGroup = "group-1" + r.UID = "rule-1" + }, withClassicConditionSingleQuery()).GenerateRef() + + fakeAIM.GenerateAlertInstances(orgID, rule.UID, 1, func(s *state.State) *state.State { + s.State = eval.Alerting + s.Labels = data.Labels{"test": "label"} + return s + }) + fakeStore.PutRule(context.Background(), rule) + + fakeProvisioning.GetProvenancesByUIDsFunc = func(ctx context.Context, orgID int64, resourceType string, uids []string) (map[string]ngmodels.Provenance, error) { + return nil, errors.New("database connection failed") + } + + req, err := http.NewRequest("GET", "/api/v1/rules?group_limit=10", nil) + require.NoError(t, err) + c := &contextmodel.ReqContext{ + Context: &web.Context{Req: req}, + SignedInUser: &user.SignedInUser{ + OrgID: orgID, + Permissions: queryPermissions, + }, + } + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusInternalServerError, resp.Status()) + + var res apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res)) + require.Equal(t, "error", res.Status) + require.Contains(t, res.Error, "failed to load provenance") + }) + + t.Run("provenance fetch error returns error response in non-paginated mode", func(t *testing.T) { + fakeStore, fakeAIM, api, fakeProvisioning := setupAPIFull(t) + + rule := gen.With(gen.WithOrgID(orgID), func(r *ngmodels.AlertRule) { + r.NamespaceUID = "ns-1" + r.RuleGroup = "group-1" + r.UID = "rule-1" + }, withClassicConditionSingleQuery()).GenerateRef() + + fakeAIM.GenerateAlertInstances(orgID, rule.UID, 1, func(s *state.State) *state.State { + s.State = eval.Alerting + s.Labels = data.Labels{"test": "label"} + return s + }) + fakeStore.PutRule(context.Background(), rule) + + fakeProvisioning.GetProvenancesFunc = func(ctx context.Context, orgID int64, resourceType string) (map[string]ngmodels.Provenance, error) { + return nil, errors.New("database connection failed") + } + + req, err := http.NewRequest("GET", "/api/v1/rules", nil) + require.NoError(t, err) + c := &contextmodel.ReqContext{ + Context: &web.Context{Req: req}, + SignedInUser: &user.SignedInUser{ + OrgID: orgID, + Permissions: queryPermissions, + }, + } + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusInternalServerError, resp.Status()) + + var res apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res)) + require.Equal(t, "error", res.Status) + require.Contains(t, res.Error, "failed to load provenance") + }) + t.Run("state filter continues when first page has no matches", func(t *testing.T) { fakeStore, fakeAIM, api := setupAPI(t) diff --git a/pkg/services/ngalert/api/prometheus/api_prometheus.go b/pkg/services/ngalert/api/prometheus/api_prometheus.go index b4e14a66cfe..077761d0caf 100644 --- a/pkg/services/ngalert/api/prometheus/api_prometheus.go +++ b/pkg/services/ngalert/api/prometheus/api_prometheus.go @@ -54,6 +54,7 @@ type StatusReader interface { type ProvenanceStore interface { GetProvenances(ctx context.Context, org int64, resourceType string) (map[string]ngmodels.Provenance, error) + GetProvenancesByUIDs(ctx context.Context, org int64, resourceType string, uids []string) (map[string]ngmodels.Provenance, error) } type PrometheusSrv struct { @@ -328,14 +329,6 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *contextmodel.ReqContext) respon span.AddEvent("User permissions checked") span.SetAttributes(attribute.Int("allowedNamespaces", len(allowedNamespaces))) - provenanceRecords, err := srv.provenanceStore.GetProvenances(c.Req.Context(), c.GetOrgID(), (&ngmodels.AlertRule{}).ResourceType()) - if err != nil { - ruleResponse.Status = "error" - ruleResponse.Error = fmt.Sprintf("failed to get provenances visible to the user: %s", err.Error()) - ruleResponse.ErrorType = apiv1.ErrServer - return response.JSON(ruleResponse.HTTPStatusCode(), ruleResponse) - } - ruleResponse = PrepareRuleGroupStatusesV2( srv.log, srv.store, @@ -347,7 +340,7 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *contextmodel.ReqContext) respon }, RuleStatusMutatorGenerator(srv.status), RuleAlertStateMutatorGenerator(srv.manager), - provenanceRecords, + srv.provenanceStore, ) return response.JSON(ruleResponse.HTTPStatusCode(), ruleResponse) @@ -454,6 +447,7 @@ func RuleAlertStateMutatorGenerator(manager state.AlertInstanceManager) RuleAler type paginationContext struct { opts RuleGroupStatusesOptions provenanceRecords map[string]ngmodels.Provenance + provenanceStore ProvenanceStore ruleStatusMutator RuleStatusMutator alertStateMutator RuleAlertStateMutator @@ -532,6 +526,37 @@ func (ctx *paginationContext) fetchAndFilterPage(log log.Logger, store ListAlert ) span.AddEvent("Alert rules retrieved from store") + // Load provenance for this page's rules + if ctx.provenanceStore != nil { + maxGroups := getInt64WithDefault(ctx.opts.Query, "group_limit", -1) + maxRules := getInt64WithDefault(ctx.opts.Query, "rule_limit", -1) + + if maxGroups > 0 || maxRules > 0 { + // Paginated, fetch and merge provenances for this page + uids := make([]string, 0, len(ruleList)) + for _, rule := range ruleList { + uids = append(uids, rule.UID) + } + pageProvenances, err := ctx.provenanceStore.GetProvenancesByUIDs(ctx.opts.Ctx, ctx.opts.OrgID, (&ngmodels.AlertRule{}).ResourceType(), uids) + if err != nil { + return pageResult{}, fmt.Errorf("failed to load provenance: %w", err) + } + if ctx.provenanceRecords == nil { + ctx.provenanceRecords = pageProvenances + } else { + maps.Copy(ctx.provenanceRecords, pageProvenances) + } + } else if ctx.provenanceRecords == nil { + // Not paginated, fetch all once + var err error + ctx.provenanceRecords, err = ctx.provenanceStore.GetProvenances(ctx.opts.Ctx, ctx.opts.OrgID, (&ngmodels.AlertRule{}).ResourceType()) + if err != nil { + return pageResult{}, fmt.Errorf("failed to load provenance: %w", err) + } + } + } + span.AddEvent("Provenances retrieved from store") + groupedRules := getGroupedRules(log, ruleList, ctx.ruleNamesSet, ctx.opts.AllowedNamespaces) result := pageResult{ @@ -643,7 +668,7 @@ func paginateRuleGroups(log log.Logger, store ListAlertRulesStoreV2, ctx *pagina return allGroups, rulesTotals, continueToken, nil } -func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opts RuleGroupStatusesOptions, ruleStatusMutator RuleStatusMutator, alertStateMutator RuleAlertStateMutator, provenanceRecords map[string]ngmodels.Provenance) apimodels.RuleResponse { +func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opts RuleGroupStatusesOptions, ruleStatusMutator RuleStatusMutator, alertStateMutator RuleAlertStateMutator, provenanceStore ProvenanceStore) apimodels.RuleResponse { ctx, span := tracer.Start(opts.Ctx, "api.prometheus.PrepareRuleGroupStatusesV2") defer span.End() opts.Ctx = ctx @@ -835,7 +860,8 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt span.SetAttributes(attribute.Bool("compact", compact)) pagCtx := &paginationContext{ opts: opts, - provenanceRecords: provenanceRecords, + provenanceRecords: nil, + provenanceStore: provenanceStore, ruleStatusMutator: ruleStatusMutator, alertStateMutator: alertStateMutator, namespaceUIDs: namespaceUIDs, diff --git a/pkg/services/ngalert/notifier/alertmanager_config.go b/pkg/services/ngalert/notifier/alertmanager_config.go index 7e755903791..ba8d37809b1 100644 --- a/pkg/services/ngalert/notifier/alertmanager_config.go +++ b/pkg/services/ngalert/notifier/alertmanager_config.go @@ -485,6 +485,7 @@ func assignReceiverConfigsUIDs(c []*definitions.PostableApiReceiver) error { type provisioningStore interface { GetProvenance(ctx context.Context, o models.Provisionable, org int64) (models.Provenance, error) GetProvenances(ctx context.Context, org int64, resourceType string) (map[string]models.Provenance, error) + GetProvenancesByUIDs(ctx context.Context, org int64, resourceType string, uids []string) (map[string]models.Provenance, error) SetProvenance(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error DeleteProvenance(ctx context.Context, o models.Provisionable, org int64) error } diff --git a/pkg/services/ngalert/provisioning/persist.go b/pkg/services/ngalert/provisioning/persist.go index 914a3644984..e6d6b37fc24 100644 --- a/pkg/services/ngalert/provisioning/persist.go +++ b/pkg/services/ngalert/provisioning/persist.go @@ -19,6 +19,7 @@ type alertmanagerConfigStore interface { type ProvisioningStore interface { GetProvenance(ctx context.Context, o models.Provisionable, org int64) (models.Provenance, error) GetProvenances(ctx context.Context, org int64, resourceType string) (map[string]models.Provenance, error) + GetProvenancesByUIDs(ctx context.Context, org int64, resourceType string, uids []string) (map[string]models.Provenance, error) SetProvenance(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error DeleteProvenance(ctx context.Context, o models.Provisionable, org int64) error } diff --git a/pkg/services/ngalert/provisioning/provisioning_store_mock.go b/pkg/services/ngalert/provisioning/provisioning_store_mock.go index 31cc77e26a6..bbc115d87c1 100644 --- a/pkg/services/ngalert/provisioning/provisioning_store_mock.go +++ b/pkg/services/ngalert/provisioning/provisioning_store_mock.go @@ -188,6 +188,67 @@ func (_c *MockProvisioningStore_GetProvenances_Call) RunAndReturn(run func(conte return _c } +// GetProvenancesByUIDs provides a mock function with given fields: ctx, org, resourceType, uids +func (_m *MockProvisioningStore) GetProvenancesByUIDs(ctx context.Context, org int64, resourceType string, uids []string) (map[string]models.Provenance, error) { + ret := _m.Called(ctx, org, resourceType, uids) + + if len(ret) == 0 { + panic("no return value specified for GetProvenancesByUIDs") + } + + var r0 map[string]models.Provenance + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int64, string, []string) (map[string]models.Provenance, error)); ok { + return rf(ctx, org, resourceType, uids) + } + if rf, ok := ret.Get(0).(func(context.Context, int64, string, []string) map[string]models.Provenance); ok { + r0 = rf(ctx, org, resourceType, uids) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]models.Provenance) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, int64, string, []string) error); ok { + r1 = rf(ctx, org, resourceType, uids) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockProvisioningStore_GetProvenancesByUIDs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetProvenancesByUIDs' +type MockProvisioningStore_GetProvenancesByUIDs_Call struct { + *mock.Call +} + +// GetProvenancesByUIDs is a helper method to define mock.On call +// - ctx context.Context +// - org int64 +// - resourceType string +// - uids []string +func (_e *MockProvisioningStore_Expecter) GetProvenancesByUIDs(ctx interface{}, org interface{}, resourceType interface{}, uids interface{}) *MockProvisioningStore_GetProvenancesByUIDs_Call { + return &MockProvisioningStore_GetProvenancesByUIDs_Call{Call: _e.mock.On("GetProvenancesByUIDs", ctx, org, resourceType, uids)} +} + +func (_c *MockProvisioningStore_GetProvenancesByUIDs_Call) Run(run func(ctx context.Context, org int64, resourceType string, uids []string)) *MockProvisioningStore_GetProvenancesByUIDs_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(int64), args[2].(string), args[3].([]string)) + }) + return _c +} + +func (_c *MockProvisioningStore_GetProvenancesByUIDs_Call) Return(_a0 map[string]models.Provenance, _a1 error) *MockProvisioningStore_GetProvenancesByUIDs_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockProvisioningStore_GetProvenancesByUIDs_Call) RunAndReturn(run func(context.Context, int64, string, []string) (map[string]models.Provenance, error)) *MockProvisioningStore_GetProvenancesByUIDs_Call { + _c.Call.Return(run) + return _c +} + // SetProvenance provides a mock function with given fields: ctx, o, org, p func (_m *MockProvisioningStore) SetProvenance(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error { ret := _m.Called(ctx, o, org, p) diff --git a/pkg/services/ngalert/store/provisioning_store.go b/pkg/services/ngalert/store/provisioning_store.go index 27f03143c98..df5d7dc80c6 100644 --- a/pkg/services/ngalert/store/provisioning_store.go +++ b/pkg/services/ngalert/store/provisioning_store.go @@ -62,6 +62,30 @@ func (st DBstore) GetProvenances(ctx context.Context, org int64, resourceType st return resultMap, err } +// GetProvenancesByUIDs gets the provenance status for specific UIDs. +func (st DBstore) GetProvenancesByUIDs(ctx context.Context, org int64, resourceType string, uids []string) (map[string]models.Provenance, error) { + if len(uids) == 0 { + return map[string]models.Provenance{}, nil + } + + result := make(map[string]models.Provenance, len(uids)) + err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error { + rawData, err := sess.Table(provenanceRecord{}). + Where("record_type = ? AND org_id = ?", resourceType, org). + In("record_key", uids). + Cols("record_key", "provenance"). + QueryString() + if err != nil { + return fmt.Errorf("failed to query for existing provenance status: %w", err) + } + for _, data := range rawData { + result[data["record_key"]] = models.Provenance(data["provenance"]) + } + return nil + }) + return result, err +} + // SetProvenance changes the provenance status for a provisionable object. func (st DBstore) SetProvenance(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error { recordType := o.ResourceType() diff --git a/pkg/services/ngalert/store/provisioning_store_test.go b/pkg/services/ngalert/store/provisioning_store_test.go index b6f8b8fe5cd..359d594da0a 100644 --- a/pkg/services/ngalert/store/provisioning_store_test.go +++ b/pkg/services/ngalert/store/provisioning_store_test.go @@ -133,6 +133,55 @@ func TestIntegrationProvisioningStore(t *testing.T) { require.Equal(t, models.ProvenanceAPI, p[rule2.UID]) }) + t.Run("Store should return provenances by UIDs", func(t *testing.T) { + const orgID = 124 + rule1 := models.AlertRule{UID: "uid-1", OrgID: orgID} + rule2 := models.AlertRule{UID: "uid-2", OrgID: orgID} + rule3 := models.AlertRule{UID: "uid-3", OrgID: orgID} + + err := store.SetProvenance(context.Background(), &rule1, orgID, models.ProvenanceFile) + require.NoError(t, err) + err = store.SetProvenance(context.Background(), &rule2, orgID, models.ProvenanceAPI) + require.NoError(t, err) + err = store.SetProvenance(context.Background(), &rule3, orgID, models.ProvenanceFile) + require.NoError(t, err) + + // Fetch only rule1 and rule2 + p, err := store.GetProvenancesByUIDs(context.Background(), orgID, rule1.ResourceType(), []string{rule1.UID, rule2.UID}) + require.NoError(t, err) + require.Len(t, p, 2) + require.Equal(t, models.ProvenanceFile, p[rule1.UID]) + require.Equal(t, models.ProvenanceAPI, p[rule2.UID]) + _, exists := p[rule3.UID] + require.False(t, exists) + }) + + t.Run("GetProvenancesByUIDs returns empty map for empty UIDs", func(t *testing.T) { + p, err := store.GetProvenancesByUIDs(context.Background(), 1, "alertRule", []string{}) + require.NoError(t, err) + require.Empty(t, p) + }) + + t.Run("GetProvenancesByUIDs respects org ID", func(t *testing.T) { + const orgID1 = 125 + const orgID2 = 126 + rule := models.AlertRule{UID: "cross-org-uid"} + + err := store.SetProvenance(context.Background(), &rule, orgID1, models.ProvenanceFile) + require.NoError(t, err) + + // Should not find in different org + p, err := store.GetProvenancesByUIDs(context.Background(), orgID2, rule.ResourceType(), []string{rule.UID}) + require.NoError(t, err) + require.Empty(t, p) + + // Should find in correct org + p, err = store.GetProvenancesByUIDs(context.Background(), orgID1, rule.ResourceType(), []string{rule.UID}) + require.NoError(t, err) + require.Len(t, p, 1) + require.Equal(t, models.ProvenanceFile, p[rule.UID]) + }) + t.Run("Store should delete provenance correctly", func(t *testing.T) { const orgID = 1234 ruleOrg := models.AlertRule{ diff --git a/pkg/services/ngalert/tests/fakes/provisioning.go b/pkg/services/ngalert/tests/fakes/provisioning.go index 43de0a6dc68..fce2586f120 100644 --- a/pkg/services/ngalert/tests/fakes/provisioning.go +++ b/pkg/services/ngalert/tests/fakes/provisioning.go @@ -8,12 +8,13 @@ import ( ) type FakeProvisioningStore struct { - Calls []Call - Records map[int64]map[string]models.Provenance - GetProvenanceFunc func(ctx context.Context, o models.Provisionable, org int64) (models.Provenance, error) - GetProvenancesFunc func(ctx context.Context, orgID int64, resourceType string) (map[string]models.Provenance, error) - SetProvenanceFunc func(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error - DeleteProvenanceFunc func(ctx context.Context, o models.Provisionable, org int64) error + Calls []Call + Records map[int64]map[string]models.Provenance + GetProvenanceFunc func(ctx context.Context, o models.Provisionable, org int64) (models.Provenance, error) + GetProvenancesFunc func(ctx context.Context, orgID int64, resourceType string) (map[string]models.Provenance, error) + GetProvenancesByUIDsFunc func(ctx context.Context, orgID int64, resourceType string, uids []string) (map[string]models.Provenance, error) + SetProvenanceFunc func(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error + DeleteProvenanceFunc func(ctx context.Context, o models.Provisionable, org int64) error } func NewFakeProvisioningStore() *FakeProvisioningStore { @@ -51,6 +52,23 @@ func (f *FakeProvisioningStore) GetProvenances(ctx context.Context, orgID int64, return results, nil } +func (f *FakeProvisioningStore) GetProvenancesByUIDs(ctx context.Context, orgID int64, resourceType string, uids []string) (map[string]models.Provenance, error) { + f.Calls = append(f.Calls, Call{MethodName: "GetProvenancesByUIDs", Arguments: []any{ctx, orgID, resourceType, uids}}) + if f.GetProvenancesByUIDsFunc != nil { + return f.GetProvenancesByUIDsFunc(ctx, orgID, resourceType, uids) + } + results := make(map[string]models.Provenance) + if val, ok := f.Records[orgID]; ok { + for _, uid := range uids { + key := uid + resourceType + if prov, ok := val[key]; ok { + results[uid] = prov + } + } + } + return results, nil +} + func (f *FakeProvisioningStore) SetProvenance(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error { f.Calls = append(f.Calls, Call{MethodName: "SetProvenance", Arguments: []any{ctx, o, org, p}}) if f.SetProvenanceFunc != nil { From fa1e6cce5e217f01c93a8cdedc344bc8122b4eea Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Fri, 26 Dec 2025 16:55:57 -0500 Subject: [PATCH 088/229] Alerting: Rule backtesting with experimental UI (#115525) * add function to convert StateTransition to LokiEntry * add QueryResultBuilder * update backtesting to produce result similar to historian * make shouldRecord public * filter out noop transitions * add experimental front-end * add new fields * move conversion of api model to AlertRule to validation * add extra labels * calculate tick timestamp using the same logic as in scheduler * implement correct logic of calculating first evaluation timestamp * add uid, group and folder uid they are needed for jitter strategy * add JitterOffsetInDuration and JitterStrategy.String() * add config `backtesting_max_evaluations` to [unified_alerting] (not documented for now) * remove obsolete tests * elevate permisisons for backtesting endpoint * move backtesting to separate dir --- pkg/services/ngalert/api/api.go | 2 +- pkg/services/ngalert/api/api_testing.go | 60 +--- pkg/services/ngalert/api/authorization.go | 10 +- .../api/tooling/definitions/testing.go | 20 +- .../api/validation/api_ruler_validation.go | 57 ++- pkg/services/ngalert/backtesting/engine.go | 235 +++++++++--- .../ngalert/backtesting/engine_test.go | 338 ++++++++++-------- pkg/services/ngalert/backtesting/eval_data.go | 5 +- .../ngalert/backtesting/eval_data_test.go | 35 +- .../ngalert/backtesting/eval_query.go | 5 +- .../ngalert/backtesting/eval_query_test.go | 33 +- pkg/services/ngalert/models/alert_rule.go | 4 + pkg/services/ngalert/schedule/jitter.go | 10 + .../ngalert/schedule/ticker/ticker.go | 6 +- pkg/services/ngalert/state/historian/core.go | 6 +- .../ngalert/state/historian/core_test.go | 2 +- pkg/services/ngalert/state/historian/loki.go | 132 ++++--- pkg/setting/setting_unified_alerting.go | 7 + .../api/alerting/api_backtesting_test.go | 3 +- .../test-data/api_backtesting_data.json | 6 + .../alerting/unified/api/backtestApi.ts | 50 +++ .../backtesting/BacktestDropdownButton.tsx | 63 ++++ .../components/backtesting/BacktestPanel.tsx | 200 +++++++++++ .../alert-rule-form/AlertRuleForm.tsx | 3 + public/locales/en-US/grafana.json | 11 +- 25 files changed, 964 insertions(+), 339 deletions(-) create mode 100644 public/app/features/alerting/unified/api/backtestApi.ts create mode 100644 public/app/features/alerting/unified/components/backtesting/BacktestDropdownButton.tsx create mode 100644 public/app/features/alerting/unified/components/backtesting/BacktestPanel.tsx diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go index eefbb6dea30..e2b60ad6e39 100644 --- a/pkg/services/ngalert/api/api.go +++ b/pkg/services/ngalert/api/api.go @@ -161,7 +161,7 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) { authz: ruleAuthzService, evaluator: api.EvaluatorFactory, cfg: &api.Cfg.UnifiedAlerting, - backtesting: backtesting.NewEngine(api.AppUrl, api.EvaluatorFactory, api.Tracer), + backtesting: backtesting.NewEngine(api.AppUrl, api.EvaluatorFactory, api.Tracer, api.Cfg.UnifiedAlerting, api.FeatureManager), featureManager: api.FeatureManager, appUrl: api.AppUrl, tracer: api.Tracer, diff --git a/pkg/services/ngalert/api/api_testing.go b/pkg/services/ngalert/api/api_testing.go index 3bda2e3f28f..13bc1a96c24 100644 --- a/pkg/services/ngalert/api/api_testing.go +++ b/pkg/services/ngalert/api/api_testing.go @@ -34,7 +34,6 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/state" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util" ) type folderService interface { @@ -230,54 +229,27 @@ func (srv TestingApiSrv) BacktestAlertRule(c *contextmodel.ReqContext, cmd apimo return ErrResp(http.StatusNotFound, nil, "Backgtesting API is not enabled") } - if cmd.From.After(cmd.To) { - return ErrResp(400, nil, "From cannot be greater than To") - } - - noDataState, err := ngmodels.NoDataStateFromString(string(cmd.NoDataState)) - + rule, err := apivalidation.ValidateBacktestConfig(c.GetOrgID(), cmd, apivalidation.RuleLimitsFromConfig(srv.cfg, srv.featureManager)) if err != nil { - return ErrResp(400, err, "") - } - forInterval := time.Duration(cmd.For) - if forInterval < 0 { - return ErrResp(400, nil, "Bad For interval") + return ErrResp(http.StatusBadRequest, err, "") } - intervalSeconds, err := apivalidation.ValidateInterval(time.Duration(cmd.Interval), srv.cfg.BaseInterval) - if err != nil { - return ErrResp(400, err, "") - } - - queries := AlertQueriesFromApiAlertQueries(cmd.Data) - if err := srv.authz.AuthorizeDatasourceAccessForRule(c.Req.Context(), c.SignedInUser, &ngmodels.AlertRule{Data: queries}); err != nil { + if err := srv.authz.AuthorizeDatasourceAccessForRule(c.Req.Context(), c.SignedInUser, rule); err != nil { return errorToResponse(err) } - rule := &ngmodels.AlertRule{ - // ID: 0, - // Updated: time.Time{}, - // Version: 0, - // NamespaceUID: "", - // DashboardUID: nil, - // PanelID: nil, - // RuleGroup: "", - // RuleGroupIndex: 0, - // ExecErrState: "", - Title: cmd.Title, - // prefix backtesting- is to distinguish between executions of regular rule and backtesting in logs (like expression engine, evaluator, state manager etc) - UID: "backtesting-" + util.GenerateShortUID(), - OrgID: c.GetOrgID(), - Condition: cmd.Condition, - Data: queries, - IntervalSeconds: intervalSeconds, - NoDataState: noDataState, - For: forInterval, - Annotations: cmd.Annotations, - Labels: cmd.Labels, + // Fetch folder path for alert labels, fallback to "Backtesting" if not available + var folderTitle string + if cmd.NamespaceUID != "" { + f, err := srv.folderService.GetNamespaceByUID(c.Req.Context(), cmd.NamespaceUID, c.OrgID, c.SignedInUser) + if err != nil { + srv.log.FromContext(c.Req.Context()).Warn("Failed to fetch folder path for alert labels", "error", err) + } else { + folderTitle = f.Fullpath + } } - result, err := srv.backtesting.Test(c.Req.Context(), c.SignedInUser, rule, cmd.From, cmd.To) + result, err := srv.backtesting.Test(c.Req.Context(), c.SignedInUser, rule, cmd.From, cmd.To, folderTitle) if err != nil { if errors.Is(err, backtesting.ErrInvalidInputData) { return ErrResp(400, err, "Failed to evaluate") @@ -285,9 +257,5 @@ func (srv TestingApiSrv) BacktestAlertRule(c *contextmodel.ReqContext, cmd apimo return ErrResp(500, err, "Failed to evaluate") } - body, err := data.FrameToJSON(result, data.IncludeAll) - if err != nil { - return ErrResp(500, err, "Failed to convert frame to JSON") - } - return response.JSON(http.StatusOK, body) + return response.JSONStreaming(http.StatusOK, result) } diff --git a/pkg/services/ngalert/api/authorization.go b/pkg/services/ngalert/api/authorization.go index 1c107db22c6..7f8b42bb3a4 100644 --- a/pkg/services/ngalert/api/authorization.go +++ b/pkg/services/ngalert/api/authorization.go @@ -81,9 +81,15 @@ func (api *API) authorize(method, path string) web.Handler { // additional authorization is done in the request handler eval = ac.EvalPermission(ac.ActionAlertingRuleRead) // Grafana Rules Testing Paths - case http.MethodPost + "/api/v1/rule/backtest": + case http.MethodPost + "/api/v1/rule/backtest": // TODO (yuri) this should be protected by dedicated permission // additional authorization is done in the request handler - eval = ac.EvalPermission(ac.ActionAlertingRuleRead) + eval = ac.EvalAll( + ac.EvalPermission(ac.ActionAlertingRuleRead), + ac.EvalAny( + ac.EvalPermission(ac.ActionAlertingRuleUpdate), + ac.EvalPermission(ac.ActionAlertingRuleCreate), + ), + ) case http.MethodPost + "/api/v1/eval": // additional authorization is done in the request handler eval = ac.EvalPermission(ac.ActionAlertingRuleRead) diff --git a/pkg/services/ngalert/api/tooling/definitions/testing.go b/pkg/services/ngalert/api/tooling/definitions/testing.go index 2c228e94758..e094c8515b3 100644 --- a/pkg/services/ngalert/api/tooling/definitions/testing.go +++ b/pkg/services/ngalert/api/tooling/definitions/testing.go @@ -221,15 +221,21 @@ type BacktestConfig struct { To time.Time `json:"to"` Interval model.Duration `json:"interval,omitempty"` - Condition string `json:"condition"` - Data []AlertQuery `json:"data"` - For model.Duration `json:"for,omitempty"` + Condition string `json:"condition"` + Data []AlertQuery `json:"data"` + For *model.Duration `json:"for,omitempty"` + KeepFiringFor *model.Duration `json:"keep_firing_for,omitempty"` - Title string `json:"title"` - Labels map[string]string `json:"labels,omitempty"` - Annotations map[string]string `json:"annotations,omitempty"` + Title string `json:"title"` + Labels map[string]string `json:"labels,omitempty"` - NoDataState NoDataState `json:"no_data_state"` + NoDataState NoDataState `json:"no_data_state"` + ExecErrState ExecutionErrorState `json:"exec_err_state"` + MissingSeriesEvalsToResolve *int64 `json:"missing_series_evals_to_resolve,omitempty"` + + UID string `json:"uid,omitempty"` + RuleGroup string `json:"rule_group,omitempty"` + NamespaceUID string `json:"namespace_uid,omitempty"` } // swagger:model diff --git a/pkg/services/ngalert/api/validation/api_ruler_validation.go b/pkg/services/ngalert/api/validation/api_ruler_validation.go index 5a74c58f90e..09e601a4711 100644 --- a/pkg/services/ngalert/api/validation/api_ruler_validation.go +++ b/pkg/services/ngalert/api/validation/api_ruler_validation.go @@ -249,6 +249,21 @@ func ValidateCondition(condition string, queries []apimodels.AlertQuery, canPatc return nil } +func validateGroupInterval(incoming prommodels.Duration, limits RuleLimits) (time.Duration, error) { + interval := time.Duration(incoming) + if interval == 0 { + // if group interval is 0 (undefined) then we automatically fall back to the default interval + interval = limits.DefaultRuleEvaluationInterval + } + + if interval < 0 || int64(interval.Seconds())%int64(limits.BaseInterval.Seconds()) != 0 { + return 0, fmt.Errorf("rule evaluation interval (%d second) should be positive number that is multiple of the base interval of %d seconds", int64(interval.Seconds()), int64(limits.BaseInterval.Seconds())) + } + + // TODO should we validate that interval is >= cfg.MinInterval? Currently, we allow to save but fix the specified interval if it is < cfg.MinInterval + return interval, nil +} + func ValidateInterval(interval, baseInterval time.Duration) (int64, error) { intervalSeconds := int64(interval.Seconds()) @@ -336,18 +351,11 @@ func ValidateRuleGroup( return nil, fmt.Errorf("rule group name is too long. Max length is %d", store.AlertRuleMaxRuleGroupNameLength) } - interval := time.Duration(ruleGroupConfig.Interval) - if interval == 0 { - // if group interval is 0 (undefined) then we automatically fall back to the default interval - interval = limits.DefaultRuleEvaluationInterval + interval, err := validateGroupInterval(ruleGroupConfig.Interval, limits) + if err != nil { + return nil, err } - if interval < 0 || int64(interval.Seconds())%int64(limits.BaseInterval.Seconds()) != 0 { - return nil, fmt.Errorf("rule evaluation interval (%d second) should be positive number that is multiple of the base interval of %d seconds", int64(interval.Seconds()), int64(limits.BaseInterval.Seconds())) - } - - // TODO should we validate that interval is >= cfg.MinInterval? Currently, we allow to save but fix the specified interval if it is < cfg.MinInterval - // If the rule group is reserved for no-group rules, we cannot have multiple rules in it. if isNoGroupRuleGroup && len(ruleGroupConfig.Rules) > 1 { return nil, fmt.Errorf("rule group %s is reserved for no-group rules and cannot be used for rule groups with multiple rules", ruleGroupConfig.Name) @@ -410,3 +418,32 @@ func ValidateNotificationSettings(n *apimodels.AlertRuleNotificationSettings) ([ s, }, nil } + +func ValidateBacktestConfig(orgId int64, config apimodels.BacktestConfig, limits RuleLimits) (*ngmodels.AlertRule, error) { + if config.From.After(config.To) { + return nil, fmt.Errorf("invalid testing range: from %s must be before to %s", config.From, config.To) + } + + interval, err := validateGroupInterval(config.Interval, limits) + if err != nil { + return nil, err + } + + return ValidateRuleNode(&apimodels.PostableExtendedRuleNode{ + ApiRuleNode: &apimodels.ApiRuleNode{ + For: config.For, + KeepFiringFor: config.KeepFiringFor, + Labels: config.Labels, + Annotations: nil, + }, + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: config.Title, + Condition: config.Condition, + Data: config.Data, + UID: config.UID, + NoDataState: config.NoDataState, + ExecErrState: config.ExecErrState, + MissingSeriesEvalsToResolve: config.MissingSeriesEvalsToResolve, + }, + }, config.RuleGroup, interval, orgId, config.NamespaceUID, limits) +} diff --git a/pkg/services/ngalert/backtesting/engine.go b/pkg/services/ngalert/backtesting/engine.go index 31c968eb234..b4a534fe134 100644 --- a/pkg/services/ngalert/backtesting/engine.go +++ b/pkg/services/ngalert/backtesting/engine.go @@ -15,10 +15,16 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/schedule" + "github.com/grafana/grafana/pkg/services/ngalert/schedule/ticker" "github.com/grafana/grafana/pkg/services/ngalert/state" + "github.com/grafana/grafana/pkg/services/ngalert/state/historian" + history_model "github.com/grafana/grafana/pkg/services/ngalert/state/historian/model" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" ) var ( @@ -28,7 +34,7 @@ var ( backtestingEvaluatorFactory = newBacktestingEvaluator ) -type callbackFunc = func(evaluationIndex int, now time.Time, results eval.Results) error +type callbackFunc = func(evaluationIndex int, now time.Time, results eval.Results) (bool, error) type backtestingEvaluator interface { Eval(ctx context.Context, from time.Time, interval time.Duration, evaluations int, callback callbackFunc) error @@ -40,11 +46,17 @@ type stateManager interface { } type Engine struct { - evalFactory eval.EvaluatorFactory - createStateManager func() stateManager + evalFactory eval.EvaluatorFactory + createStateManager func() stateManager + disableGrafanaFolder bool + featureToggles featuremgmt.FeatureToggles + minInterval time.Duration + baseInterval time.Duration + jitterStrategy schedule.JitterStrategy + maxEvaluations int } -func NewEngine(appUrl *url.URL, evalFactory eval.EvaluatorFactory, tracer tracing.Tracer) *Engine { +func NewEngine(appUrl *url.URL, evalFactory eval.EvaluatorFactory, tracer tracing.Tracer, cfg setting.UnifiedAlertingSettings, toggles featuremgmt.FeatureToggles) *Engine { return &Engine{ evalFactory: evalFactory, createStateManager: func() stateManager { @@ -60,74 +72,139 @@ func NewEngine(appUrl *url.URL, evalFactory eval.EvaluatorFactory, tracer tracin } return state.NewManager(cfg, state.NewNoopPersister()) }, + disableGrafanaFolder: false, + featureToggles: toggles, + minInterval: cfg.MinInterval, + baseInterval: cfg.BaseInterval, + maxEvaluations: cfg.BacktestingMaxEvaluations, + jitterStrategy: schedule.JitterStrategyFrom(cfg, toggles), } } -func (e *Engine) Test(ctx context.Context, user identity.Requester, rule *models.AlertRule, from, to time.Time) (*data.Frame, error) { - ruleCtx := models.WithRuleKey(ctx, rule.GetKey()) - logger := logger.FromContext(ctx) - +func (e *Engine) Test(ctx context.Context, user identity.Requester, rule *models.AlertRule, from, to time.Time, folderTitle string) (res *data.Frame, err error) { + if rule == nil { + return nil, fmt.Errorf("%w: rule is not defined", ErrInvalidInputData) + } if !from.Before(to) { - return nil, fmt.Errorf("%w: invalid interval of the backtesting [%d,%d]", ErrInvalidInputData, from.Unix(), to.Unix()) + return nil, fmt.Errorf("%w: invalid interval [%d,%d]", ErrInvalidInputData, from.Unix(), to.Unix()) } - if to.Sub(from).Seconds() < float64(rule.IntervalSeconds) { - return nil, fmt.Errorf("%w: interval of the backtesting [%d,%d] is less than evaluation interval [%ds]", ErrInvalidInputData, from.Unix(), to.Unix(), rule.IntervalSeconds) + + ruleCtx := models.WithRuleKey(ctx, rule.GetKey()) + logger := logger.FromContext(ruleCtx).New("backtesting", util.GenerateShortUID()) + + var warns []string + if rule.GetInterval() < e.minInterval { + logger.Warn("Interval adjusted to minimal interval", "originalInterval", rule.GetInterval(), "adjustedInterval", e.minInterval) + rule = rule.Copy() + rule.IntervalSeconds = int64(e.minInterval.Seconds()) + warns = append(warns, fmt.Sprintf("Interval adjusted to minimal interval %ds", rule.IntervalSeconds)) } - length := int(to.Sub(from).Seconds()) / int(rule.IntervalSeconds) - stateManager := e.createStateManager() + effectiveStrategy := e.jitterStrategy + if e.jitterStrategy == schedule.JitterByGroup && (rule.RuleGroup == "" || rule.NamespaceUID == "") || + e.jitterStrategy == schedule.JitterByRule && rule.UID == "" { + logger.Warn(fmt.Sprintf("Jitter strategy is set to %s, but rule group or namespace is not set. Ignore jitter", e.jitterStrategy)) + warns = append(warns, fmt.Sprintf("Jitter strategy is set to %s, but rule group or namespace is not set. Ignore jitter. The results of testing will be different than real evaluations", e.jitterStrategy)) + effectiveStrategy = schedule.JitterNever + } + jitterOffset := schedule.JitterOffsetInDuration(rule, e.baseInterval, effectiveStrategy) + firstEval, err := getFirstEvaluationTime(from, rule, e.baseInterval, jitterOffset) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrInvalidInputData, err) + } - evaluator, err := backtestingEvaluatorFactory(ruleCtx, e.evalFactory, user, rule.GetEvalCondition().WithSource("backtesting"), &schedule.AlertingResultsFromRuleState{ - Manager: stateManager, - Rule: rule, - }) + evaluations := calculateNumberOfEvaluations(firstEval, to, rule.GetInterval()) + if e.maxEvaluations > 0 && evaluations > e.maxEvaluations { + logger.Warn("Evaluations adjusted to maximal number", "originalEvaluations", evaluations, "adjustedEvaluations", e.maxEvaluations) + warns = append(warns, fmt.Sprintf("Number of evaluations are adjusted to the limit of %d evaluations. Requested: %d", e.maxEvaluations, evaluations)) + evaluations = e.maxEvaluations + } + + start := time.Now() + defer func() { + if err == nil { + logger.Info("Rule testing finished successfully", "duration", time.Since(start)) + } else { + logger.Error("Rule testing finished with error", "duration", time.Since(start), "error", err) + } + }() + + stateMgr := e.createStateManager() + + evaluator, err := backtestingEvaluatorFactory(ruleCtx, + e.evalFactory, + user, + rule.GetEvalCondition().WithSource("backtesting"), + &schedule.AlertingResultsFromRuleState{ + Manager: stateMgr, + Rule: rule, + }, + ) if err != nil { return nil, errors.Join(ErrInvalidInputData, err) } - logger.Info("Start testing alert rule", "from", from, "to", to, "interval", rule.IntervalSeconds, "evaluations", length) + logger.Info("Start testing alert rule", "from", from, "to", to, "interval", rule.GetInterval(), "firstTick", firstEval, "evaluations", evaluations, "jitterOffset", jitterOffset, "jitterStrategy", effectiveStrategy) - start := time.Now() + var builder *historian.QueryResultBuilder - tsField := data.NewField("Time", nil, make([]time.Time, length)) - valueFields := make(map[data.Fingerprint]*data.Field) - - err = evaluator.Eval(ruleCtx, from, time.Duration(rule.IntervalSeconds)*time.Second, length, func(idx int, currentTime time.Time, results eval.Results) error { - if idx >= length { - logger.Info("Unexpected evaluation. Skipping", "from", from, "to", to, "interval", rule.IntervalSeconds, "evaluationTime", currentTime, "evaluationIndex", idx, "expectedEvaluations", length) - return nil - } - states := stateManager.ProcessEvalResults(ruleCtx, currentTime, rule, results, nil, nil) - tsField.Set(idx, currentTime) - for _, s := range states { - field, ok := valueFields[s.CacheID] - if !ok { - field = data.NewField("", s.Labels, make([]*string, length)) - valueFields[s.CacheID] = field - } - if s.State.State != eval.NoData { // set nil if NoData - value := s.State.State.String() - if s.StateReason != "" { - value += " (" + s.StateReason + ")" - } - field.Set(idx, &value) - continue - } - } - return nil - }) - fields := make([]*data.Field, 0, len(valueFields)+1) - fields = append(fields, tsField) - for _, f := range valueFields { - fields = append(fields, f) + ruleMeta := history_model.RuleMeta{ + ID: rule.ID, + OrgID: rule.OrgID, + UID: rule.UID, + Title: rule.Title, + Group: rule.RuleGroup, + NamespaceUID: rule.NamespaceUID, + // DashboardUID: "", + // PanelID: 0, + Condition: rule.Condition, } - result := data.NewFrame("Testing results", fields...) - + labels := map[string]string{ + historian.OrgIDLabel: fmt.Sprint(ruleMeta.OrgID), + historian.GroupLabel: fmt.Sprint(ruleMeta.Group), + historian.FolderUIDLabel: fmt.Sprint(rule.NamespaceUID), + } + labelsBytes, err := json.Marshal(labels) if err != nil { return nil, err } - logger.Info("Rule testing finished successfully", "duration", time.Since(start)) - return result, nil + + // Ensure fallback if empty string is passed + if folderTitle == "" { + folderTitle = "Backtesting" + } + extraLabels := state.GetRuleExtraLabels(logger, rule, folderTitle, !e.disableGrafanaFolder, e.featureToggles) + + processFn := func(idx int, currentTime time.Time, results eval.Results) (bool, error) { + // init the builder. Do the best guess for the size of the result + if builder == nil { + builder = historian.NewQueryResultBuilder(evaluations * len(results)) + for _, warn := range warns { + builder.AddWarn(warn) + } + } + states := stateMgr.ProcessEvalResults(ruleCtx, currentTime, rule, results, extraLabels, nil) + for _, s := range states { + if !historian.ShouldRecord(s) { + continue + } + entry := historian.StateTransitionToLokiEntry(ruleMeta, s) + err := builder.AddRow(currentTime, entry, labelsBytes) + if err != nil { + return false, err + } + } + return idx <= evaluations, nil + } + + err = evaluator.Eval(ruleCtx, firstEval, rule.GetInterval(), evaluations, processFn) + if err != nil { + return nil, err + } + if builder == nil { + return nil, errors.New("no results were produced") + } + return builder.ToFrame(), nil } func newBacktestingEvaluator(ctx context.Context, evalFactory eval.EvaluatorFactory, user identity.Requester, condition models.Condition, reader eval.AlertingResultsReader) (backtestingEvaluator, error) { @@ -173,3 +250,53 @@ type NoopImageService struct{} func (s *NoopImageService) NewImage(_ context.Context, _ *models.AlertRule) (*models.Image, error) { return &models.Image{}, nil } + +func getNextEvaluationTime(currentTime time.Time, rule *models.AlertRule, baseInterval time.Duration, jitterOffset time.Duration) (time.Time, error) { + if rule.IntervalSeconds%int64(baseInterval.Seconds()) != 0 { + return time.Time{}, fmt.Errorf("interval %ds is not divisible by base interval %ds", rule.IntervalSeconds, int64(baseInterval.Seconds())) + } + + freq := rule.IntervalSeconds / int64(baseInterval.Seconds()) + + firstTickNum := currentTime.Unix() / int64(baseInterval.Seconds()) + + jitterOffsetTicks := int64(jitterOffset / baseInterval) + + firstEvalTickNum := firstTickNum + (jitterOffsetTicks-(firstTickNum%freq)+freq)%freq + + return time.Unix(firstEvalTickNum*int64(baseInterval.Seconds()), 0), nil +} + +func getFirstEvaluationTime(from time.Time, rule *models.AlertRule, baseInterval time.Duration, jitterOffset time.Duration) (time.Time, error) { + // Now calculate the time of the tick the same way as in the scheduler + firstTick := ticker.GetStartTick(from, baseInterval) + + // calculate time of the first evaluation that is at or after the first tick + firstEval, err := getNextEvaluationTime(firstTick, rule, baseInterval, jitterOffset) + if err != nil { + return time.Time{}, err + } + + // Ensure firstEval is at or after from + // Calculate how many intervals to skip to get past 'from' + if firstEval.Before(from) { + diff := from.Sub(firstEval) + interval := rule.GetInterval() + // Ceiling division: how many intervals needed to cover the difference + intervalsToAdd := (diff + interval - 1) / interval + firstEval = firstEval.Add(interval * intervalsToAdd) + } + + return firstEval, nil +} + +func calculateNumberOfEvaluations(firstEval, to time.Time, interval time.Duration) int { + var evaluations int + if to.After(firstEval) { + evaluations = int(to.Sub(firstEval).Seconds()) / int(interval.Seconds()) + } + if evaluations == 0 { + evaluations = 1 + } + return evaluations +} diff --git a/pkg/services/ngalert/backtesting/engine_test.go b/pkg/services/ngalert/backtesting/engine_test.go index d2685e71535..33441d73f32 100644 --- a/pkg/services/ngalert/backtesting/engine_test.go +++ b/pkg/services/ngalert/backtesting/engine_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "math/rand" "testing" "time" @@ -14,9 +13,11 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/eval/eval_mocks" "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/schedule" "github.com/grafana/grafana/pkg/services/ngalert/state" "github.com/grafana/grafana/pkg/util" ) @@ -158,16 +159,6 @@ func TestNewBacktestingEvaluator(t *testing.T) { } func TestEvaluatorTest(t *testing.T) { - states := []eval.State{eval.Normal, eval.Alerting, eval.Pending} - generateState := func(prefix string) *state.State { - labels := models.GenerateAlertLabels(rand.Intn(5)+1, prefix+"-") - return &state.State{ - CacheID: labels.Fingerprint(), - Labels: labels, - State: states[rand.Intn(len(states))], - } - } - randomResultCallback := func(now time.Time) (eval.Results, error) { return eval.GenerateResults(rand.Intn(5)+1, eval.ResultGen()), nil } @@ -189,84 +180,17 @@ func TestEvaluatorTest(t *testing.T) { createStateManager: func() stateManager { return manager }, + disableGrafanaFolder: false, + featureToggles: featuremgmt.WithFeatures(), + minInterval: 1 * time.Second, + baseInterval: 1 * time.Second, + jitterStrategy: schedule.JitterNever, + maxEvaluations: 10000, } gen := models.RuleGen rule := gen.With(gen.WithInterval(time.Second)).GenerateRef() ruleInterval := time.Duration(rule.IntervalSeconds) * time.Second - t.Run("should return data frame in specific format", func(t *testing.T) { - from := time.Unix(0, 0) - to := from.Add(5 * ruleInterval) - allStates := [...]eval.State{eval.Normal, eval.Alerting, eval.Pending, eval.NoData, eval.Error} - - var states []state.StateTransition - - for _, s := range allStates { - labels := models.GenerateAlertLabels(rand.Intn(5)+1, s.String()+"-") - states = append(states, state.StateTransition{ - State: &state.State{ - CacheID: labels.Fingerprint(), - Labels: labels, - State: s, - StateReason: util.GenerateShortUID(), - }, - }) - } - - manager.stateCallback = func(now time.Time) []state.StateTransition { - return states - } - - frame, err := engine.Test(context.Background(), nil, rule, from, to) - - require.NoError(t, err) - require.Len(t, frame.Fields, len(states)+1) // +1 - timestamp - - t.Run("should contain field Time", func(t *testing.T) { - timestampField, _ := frame.FieldByName("Time") - require.NotNil(t, timestampField, "frame does not contain field 'Time'") - require.Equal(t, data.FieldTypeTime, timestampField.Type()) - }) - - fieldByState := make(map[data.Fingerprint]*data.Field, len(states)) - - t.Run("should contain a field per state", func(t *testing.T) { - for _, s := range states { - var f *data.Field - for _, field := range frame.Fields { - if field.Labels.String() == s.Labels.String() { - f = field - break - } - } - require.NotNilf(t, f, "Cannot find a field by state labels") - fieldByState[s.CacheID] = f - } - }) - - t.Run("should be populated with correct values", func(t *testing.T) { - timestampField, _ := frame.FieldByName("Time") - expectedLength := timestampField.Len() - for _, field := range frame.Fields { - require.Equalf(t, expectedLength, field.Len(), "Field %s should have the size %d", field.Name, expectedLength) - } - for i := 0; i < expectedLength; i++ { - expectedTime := from.Add(time.Duration(int64(i)*rule.IntervalSeconds) * time.Second) - require.Equal(t, expectedTime, timestampField.At(i).(time.Time)) - for _, s := range states { - f := fieldByState[s.CacheID] - if s.State.State == eval.NoData { - require.Nil(t, f.At(i)) - } else { - v := f.At(i).(*string) - require.NotNilf(t, v, "Field [%s] value at index %d should not be nil", s.CacheID, i) - require.Equal(t, fmt.Sprintf("%s (%s)", s.State.State, s.StateReason), *v) - } - } - } - }) - }) - t.Run("should not fail if 'to-from' is not times of interval", func(t *testing.T) { from := time.Unix(0, 0) to := from.Add(5 * ruleInterval) @@ -287,84 +211,26 @@ func TestEvaluatorTest(t *testing.T) { return states } - frame, err := engine.Test(context.Background(), nil, rule, from, to) + frame, err := engine.Test(context.Background(), nil, rule, from, to, "") require.NoError(t, err) expectedLen := frame.Rows() for i := 0; i < 100; i++ { jitter := time.Duration(rand.Int63n(ruleInterval.Milliseconds())) * time.Millisecond - frame, err = engine.Test(context.Background(), nil, rule, from, to.Add(jitter)) + frame, err = engine.Test(context.Background(), nil, rule, from, to.Add(jitter), "") require.NoError(t, err) require.Equalf(t, expectedLen, frame.Rows(), "jitter %v caused result to be different that base-line", jitter) } }) - t.Run("should backfill field with nulls if a new dimension created in the middle", func(t *testing.T) { - from := time.Unix(0, 0) - - state1 := state.StateTransition{ - State: generateState("1"), - } - state2 := state.StateTransition{ - State: generateState("2"), - } - state3 := state.StateTransition{ - State: generateState("3"), - } - stateByTime := map[time.Time][]state.StateTransition{ - from: {state1, state2}, - from.Add(1 * ruleInterval): {state1, state2}, - from.Add(2 * ruleInterval): {state1, state2}, - from.Add(3 * ruleInterval): {state1, state2, state3}, - from.Add(4 * ruleInterval): {state1, state2, state3}, - } - to := from.Add(time.Duration(len(stateByTime)) * ruleInterval) - - manager.stateCallback = func(now time.Time) []state.StateTransition { - return stateByTime[now] - } - - frame, err := engine.Test(context.Background(), nil, rule, from, to) - require.NoError(t, err) - - var field3 *data.Field - for _, field := range frame.Fields { - if field.Labels.String() == state3.Labels.String() { - field3 = field - break - } - } - require.NotNilf(t, field3, "Result for state 3 was not found") - require.Equalf(t, len(stateByTime), field3.Len(), "State3 result has unexpected number of values") - - idx := 0 - for curTime, states := range stateByTime { - value := field3.At(idx).(*string) - if len(states) == 2 { - require.Nilf(t, value, "The result should be nil if state3 was not available for time %v", curTime) - } - } - }) - t.Run("should fail", func(t *testing.T) { manager.stateCallback = func(now time.Time) []state.StateTransition { return nil } - t.Run("when interval is not correct", func(t *testing.T) { from := time.Now() - t.Run("when from=to", func(t *testing.T) { - to := from - _, err := engine.Test(context.Background(), nil, rule, from, to) - require.ErrorIs(t, err, ErrInvalidInputData) - }) t.Run("when from > to", func(t *testing.T) { to := from.Add(-ruleInterval) - _, err := engine.Test(context.Background(), nil, rule, from, to) - require.ErrorIs(t, err, ErrInvalidInputData) - }) - t.Run("when to-from < interval", func(t *testing.T) { - to := from.Add(ruleInterval).Add(-time.Millisecond) - _, err := engine.Test(context.Background(), nil, rule, from, to) + _, err := engine.Test(context.Background(), nil, rule, from, to, "") require.ErrorIs(t, err, ErrInvalidInputData) }) }) @@ -376,7 +242,7 @@ func TestEvaluatorTest(t *testing.T) { } from := time.Now() to := from.Add(ruleInterval) - _, err := engine.Test(context.Background(), nil, rule, from, to) + _, err := engine.Test(context.Background(), nil, rule, from, to, "") require.ErrorIs(t, err, expectedError) }) }) @@ -404,10 +270,188 @@ func (f *fakeBacktestingEvaluator) Eval(_ context.Context, from time.Time, inter if err != nil { return err } - err = callback(idx, now, results) + c, err := callback(idx, now, results) if err != nil { return err } + if !c { + break + } } return nil } + +func TestGetNextEvaluationTime(t *testing.T) { + baseInterval := 10 * time.Second + + testCases := []struct { + name string + ruleInterval int64 + currentTimestamp int64 + jitterOffset time.Duration + expectError bool + expectedNext int64 + }{ + { + name: "interval not divisible by base interval", + ruleInterval: 15, + currentTimestamp: 0, + jitterOffset: 0, + expectError: true, + }, + { + name: "no jitter - from tick 0", + ruleInterval: 20, + currentTimestamp: 0, + jitterOffset: 0, + expectedNext: 0, + }, + { + name: "no jitter - from tick 1", + ruleInterval: 20, + currentTimestamp: 10, + jitterOffset: 0, + expectedNext: 20, + }, + { + name: "no jitter - from tick 2", + ruleInterval: 20, + currentTimestamp: 20, + jitterOffset: 0, + expectedNext: 20, + }, + { + name: "with 20s jitter - from tick 0", + ruleInterval: 60, + currentTimestamp: 0, + jitterOffset: 20 * time.Second, + expectedNext: 20, + }, + { + name: "with 20s jitter - from tick 2", + ruleInterval: 60, + currentTimestamp: 20, + jitterOffset: 20 * time.Second, + expectedNext: 20, + }, + { + name: "with 20s jitter - from tick 3", + ruleInterval: 60, + currentTimestamp: 30, + jitterOffset: 20 * time.Second, + expectedNext: 80, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + rule := &models.AlertRule{IntervalSeconds: tc.ruleInterval} + currentTime := time.Unix(tc.currentTimestamp, 0) + result, err := getNextEvaluationTime(currentTime, rule, baseInterval, tc.jitterOffset) + + if tc.expectError { + require.Error(t, err) + require.Contains(t, err.Error(), "is not divisible by base interval") + return + } + + require.NoError(t, err) + require.Equal(t, tc.expectedNext, result.Unix()) + }) + } +} + +func TestGetFirstEvaluationTime(t *testing.T) { + baseInterval := 10 * time.Second + + testCases := []struct { + name string + ruleInterval int64 + fromUnix int64 + jitterOffset time.Duration + expectError bool + expectedUnix int64 + }{ + { + name: "interval not divisible by base interval", + ruleInterval: 15, + fromUnix: 0, + jitterOffset: 0, + expectError: true, + }, + { + name: "no jitter - from at tick 0", + ruleInterval: 20, + fromUnix: 0, + jitterOffset: 0, + expectedUnix: 0, + }, + { + name: "no jitter - from at tick 1", + ruleInterval: 20, + fromUnix: 10, + jitterOffset: 0, + expectedUnix: 20, + }, + { + name: "no jitter - from before first tick", + ruleInterval: 20, + fromUnix: 5, + jitterOffset: 0, + expectedUnix: 20, + }, + { + name: "no jitter - from after first aligned tick", + ruleInterval: 20, + fromUnix: 25, + jitterOffset: 0, + expectedUnix: 40, + }, + { + name: "no jitter - from at tick boundary", + ruleInterval: 10, + fromUnix: 10, + jitterOffset: 0, + expectedUnix: 10, + }, + { + name: "with 20s jitter - from epoch", + ruleInterval: 60, + fromUnix: 0, + jitterOffset: 20 * time.Second, + expectedUnix: 20, + }, + { + name: "with 20s jitter - from 70s", + ruleInterval: 60, + fromUnix: 70, + jitterOffset: 20 * time.Second, + expectedUnix: 80, + }, + { + name: "with 50s jitter - from 25s", + ruleInterval: 60, + fromUnix: 25, + jitterOffset: 50 * time.Second, + expectedUnix: 50, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + rule := &models.AlertRule{IntervalSeconds: tc.ruleInterval} + from := time.Unix(tc.fromUnix, 0) + result, err := getFirstEvaluationTime(from, rule, baseInterval, tc.jitterOffset) + + if tc.expectError { + require.Error(t, err) + require.Contains(t, err.Error(), "is not divisible by base interval") + return + } + + require.NoError(t, err) + require.Equal(t, tc.expectedUnix, result.Unix()) + require.GreaterOrEqual(t, result.Unix(), from.Unix(), "first eval should be at or after from") + }) + } +} diff --git a/pkg/services/ngalert/backtesting/eval_data.go b/pkg/services/ngalert/backtesting/eval_data.go index 999c0bc6302..13827e6e757 100644 --- a/pkg/services/ngalert/backtesting/eval_data.go +++ b/pkg/services/ngalert/backtesting/eval_data.go @@ -85,10 +85,13 @@ func (d *dataEvaluator) Eval(_ context.Context, from time.Time, interval time.Du EvaluatedAt: now, }) } - err := callback(i, now, result) + cont, err := callback(i, now, result) if err != nil { return err } + if !cont { + break + } } return nil } diff --git a/pkg/services/ngalert/backtesting/eval_data_test.go b/pkg/services/ngalert/backtesting/eval_data_test.go index 864229b777c..3d80fa9337a 100644 --- a/pkg/services/ngalert/backtesting/eval_data_test.go +++ b/pkg/services/ngalert/backtesting/eval_data_test.go @@ -100,11 +100,11 @@ func TestDataEvaluator_Eval(t *testing.T) { resultsCount := int(to.Sub(from).Seconds() / interval.Seconds()) - err = evaluator.Eval(context.Background(), from, time.Second, resultsCount, func(idx int, now time.Time, res eval.Results) error { + err = evaluator.Eval(context.Background(), from, time.Second, resultsCount, func(idx int, now time.Time, res eval.Results) (bool, error) { r = append(r, results{ now, res, }) - return nil + return true, nil }) require.NoError(t, err) @@ -164,11 +164,11 @@ func TestDataEvaluator_Eval(t *testing.T) { size := to.Sub(from).Milliseconds() / interval.Milliseconds() r := make([]results, 0, size) - err = evaluator.Eval(context.Background(), from, interval, int(size), func(idx int, now time.Time, res eval.Results) error { + err = evaluator.Eval(context.Background(), from, interval, int(size), func(idx int, now time.Time, res eval.Results) (bool, error) { r = append(r, results{ now, res, }) - return nil + return true, nil }) currentRowIdx := 0 @@ -195,11 +195,11 @@ func TestDataEvaluator_Eval(t *testing.T) { size := int(to.Sub(from).Seconds() / interval.Seconds()) r := make([]results, 0, size) - err = evaluator.Eval(context.Background(), from, interval, size, func(idx int, now time.Time, res eval.Results) error { + err = evaluator.Eval(context.Background(), from, interval, size, func(idx int, now time.Time, res eval.Results) (bool, error) { r = append(r, results{ now, res, }) - return nil + return true, nil }) currentRowIdx := 0 @@ -230,11 +230,11 @@ func TestDataEvaluator_Eval(t *testing.T) { t.Run("should be noData until the frame interval", func(t *testing.T) { newFrom := from.Add(-10 * time.Second) r := make([]results, 0, int(to.Sub(newFrom).Seconds())) - err = evaluator.Eval(context.Background(), newFrom, time.Second, cap(r), func(idx int, now time.Time, res eval.Results) error { + err = evaluator.Eval(context.Background(), newFrom, time.Second, cap(r), func(idx int, now time.Time, res eval.Results) (bool, error) { r = append(r, results{ now, res, }) - return nil + return true, nil }) rowIdx := 0 @@ -258,11 +258,11 @@ func TestDataEvaluator_Eval(t *testing.T) { t.Run("should be the last value after the frame interval", func(t *testing.T) { newTo := to.Add(10 * time.Second) r := make([]results, 0, int(newTo.Sub(from).Seconds())) - err = evaluator.Eval(context.Background(), from, time.Second, cap(r), func(idx int, now time.Time, res eval.Results) error { + err = evaluator.Eval(context.Background(), from, time.Second, cap(r), func(idx int, now time.Time, res eval.Results) (bool, error) { r = append(r, results{ now, res, }) - return nil + return true, nil }) rowIdx := 0 @@ -282,12 +282,21 @@ func TestDataEvaluator_Eval(t *testing.T) { }) t.Run("should stop if callback error", func(t *testing.T) { expectedError := errors.New("error") - err = evaluator.Eval(context.Background(), from, time.Second, 6, func(idx int, now time.Time, res eval.Results) error { + err = evaluator.Eval(context.Background(), from, time.Second, 6, func(idx int, now time.Time, res eval.Results) (bool, error) { if idx == 5 { - return expectedError + return false, expectedError } - return nil + return true, nil }) require.ErrorIs(t, err, expectedError) }) + t.Run("should stop if callback does not want to continue", func(t *testing.T) { + evaluated := 0 + err = evaluator.Eval(context.Background(), from, time.Second, 6, func(idx int, now time.Time, res eval.Results) (bool, error) { + evaluated++ + return evaluated < 2, nil + }) + require.NoError(t, err) + require.Equal(t, 2, evaluated) + }) } diff --git a/pkg/services/ngalert/backtesting/eval_query.go b/pkg/services/ngalert/backtesting/eval_query.go index f53e3de86cb..07720f4f265 100644 --- a/pkg/services/ngalert/backtesting/eval_query.go +++ b/pkg/services/ngalert/backtesting/eval_query.go @@ -18,10 +18,13 @@ func (d *queryEvaluator) Eval(ctx context.Context, from time.Time, interval time if err != nil { return err } - err = callback(idx, now, results) + cont, err := callback(idx, now, results) if err != nil { return err } + if !cont { + break + } } return nil } diff --git a/pkg/services/ngalert/backtesting/eval_query_test.go b/pkg/services/ngalert/backtesting/eval_query_test.go index e88948971f0..4c9df9d25b1 100644 --- a/pkg/services/ngalert/backtesting/eval_query_test.go +++ b/pkg/services/ngalert/backtesting/eval_query_test.go @@ -31,9 +31,9 @@ func TestQueryEvaluator_Eval(t *testing.T) { intervals := make([]time.Time, times) - err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) error { + err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) (bool, error) { intervals[idx] = now - return nil + return true, nil }) require.NoError(t, err) require.Len(t, intervals, times) @@ -49,7 +49,7 @@ func TestQueryEvaluator_Eval(t *testing.T) { } }) - t.Run("should stop evaluation if error", func(t *testing.T) { + t.Run("should stop evaluation", func(t *testing.T) { t.Run("when evaluation fails", func(t *testing.T) { m := &eval_mocks.ConditionEvaluatorMock{} expectedResults := eval.Results{} @@ -62,9 +62,9 @@ func TestQueryEvaluator_Eval(t *testing.T) { intervals := make([]time.Time, 0, times) - err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) error { + err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) (bool, error) { intervals = append(intervals, now) - return nil + return true, nil }) require.ErrorIs(t, err, expectedError) require.Len(t, intervals, 3) @@ -81,14 +81,31 @@ func TestQueryEvaluator_Eval(t *testing.T) { intervals := make([]time.Time, 0, times) - err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) error { + err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) (bool, error) { if len(intervals) > 3 { - return expectedError + return false, expectedError } intervals = append(intervals, now) - return nil + return true, nil }) require.ErrorIs(t, err, expectedError) }) + + t.Run("when callback does not want to continue", func(t *testing.T) { + m := &eval_mocks.ConditionEvaluatorMock{} + expectedResults := eval.Results{} + m.EXPECT().Evaluate(mock.Anything, mock.Anything).Return(expectedResults, nil) + evaluator := queryEvaluator{ + eval: m, + } + + evaluated := 0 + err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) (bool, error) { + evaluated++ + return evaluated <= 2, nil + }) + require.NoError(t, err, nil) + require.Equal(t, 3, evaluated) + }) }) } diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index f7bb3d9fcfd..7d6e8a1fab5 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -480,6 +480,10 @@ func (alertRule *AlertRule) GetPanelID() int64 { return -1 } +func (alertRule *AlertRule) GetInterval() time.Duration { + return time.Duration(alertRule.IntervalSeconds) * time.Second +} + type LabelOption func(map[string]string) func WithoutInternalLabels() LabelOption { diff --git a/pkg/services/ngalert/schedule/jitter.go b/pkg/services/ngalert/schedule/jitter.go index 3d6c839f372..a805ab9b0b3 100644 --- a/pkg/services/ngalert/schedule/jitter.go +++ b/pkg/services/ngalert/schedule/jitter.go @@ -5,6 +5,7 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/services/featuremgmt" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/setting" @@ -13,6 +14,10 @@ import ( // JitterStrategy represents a modifier to alert rule timing that affects how evaluations are distributed. type JitterStrategy int +func (s JitterStrategy) String() string { + return [...]string{"never", "by group", "by rule"}[s] +} + const ( JitterNever JitterStrategy = iota JitterByGroup @@ -57,6 +62,11 @@ func jitterOffsetInTicks(r *ngmodels.AlertRule, baseInterval time.Duration, stra return res } +// JitterOffsetInDuration gives the jitter offset for a rule, in terms of a duration relative to its interval and a base interval. +func JitterOffsetInDuration(r *ngmodels.AlertRule, baseInterval time.Duration, strategy JitterStrategy) time.Duration { + return time.Duration(jitterOffsetInTicks(r, baseInterval, strategy)) * baseInterval +} + func jitterHash(r *ngmodels.AlertRule, strategy JitterStrategy) uint64 { ls := data.Labels{ "name": r.RuleGroup, diff --git a/pkg/services/ngalert/schedule/ticker/ticker.go b/pkg/services/ngalert/schedule/ticker/ticker.go index a52b9c4e559..c24dc13798a 100644 --- a/pkg/services/ngalert/schedule/ticker/ticker.go +++ b/pkg/services/ngalert/schedule/ticker/ticker.go @@ -44,7 +44,11 @@ func New(c clock.Clock, interval time.Duration, metric *Metrics, logger log.Logg } func getStartTick(clk clock.Clock, interval time.Duration) time.Time { - nano := clk.Now().UnixNano() + return GetStartTick(clk.Now(), interval) +} + +func GetStartTick(t time.Time, interval time.Duration) time.Time { + nano := t.UnixNano() return time.Unix(0, nano-(nano%interval.Nanoseconds())) } diff --git a/pkg/services/ngalert/state/historian/core.go b/pkg/services/ngalert/state/historian/core.go index eabb5214a9e..daf415be25a 100644 --- a/pkg/services/ngalert/state/historian/core.go +++ b/pkg/services/ngalert/state/historian/core.go @@ -17,7 +17,7 @@ import ( const StateHistoryWriteTimeout = time.Minute -func shouldRecord(transition state.StateTransition) bool { +func ShouldRecord(transition state.StateTransition) bool { if !transition.Changed() { return false } @@ -35,9 +35,9 @@ func shouldRecord(transition state.StateTransition) bool { } // ShouldRecordAnnotation returns true if an annotation should be created for a given state transition. -// This is stricter than shouldRecord to avoid cluttering panels with state transitions. +// This is stricter than ShouldRecord to avoid cluttering panels with state transitions. func ShouldRecordAnnotation(t state.StateTransition) bool { - if !shouldRecord(t) { + if !ShouldRecord(t) { return false } diff --git a/pkg/services/ngalert/state/historian/core_test.go b/pkg/services/ngalert/state/historian/core_test.go index f798bd26cef..a5f0c55d816 100644 --- a/pkg/services/ngalert/state/historian/core_test.go +++ b/pkg/services/ngalert/state/historian/core_test.go @@ -92,7 +92,7 @@ func TestShouldRecord(t *testing.T) { } t.Run(fmt.Sprintf("%s -> %s should be %v", trans.PreviousFormatted(), trans.Formatted(), !ok), func(t *testing.T) { - require.Equal(t, !ok, shouldRecord(trans)) + require.Equal(t, !ok, ShouldRecord(trans)) }) } } diff --git a/pkg/services/ngalert/state/historian/loki.go b/pkg/services/ngalert/state/historian/loki.go index 76e3e9025bd..8e98d411c1e 100644 --- a/pkg/services/ngalert/state/historian/loki.go +++ b/pkg/services/ngalert/state/historian/loki.go @@ -41,6 +41,69 @@ const ( dfLabels = "labels" ) +// QueryResultBuilder is a builder for a data frame that represents query results from Loki. +// It contains three fields: time (timestamp), line (JSON data), and labels (JSON labels). +type QueryResultBuilder struct { + frame *data.Frame +} + +// NewQueryResultBuilder creates a new QueryResultBuilder with the specified capacity. +// The capacity is used to pre-allocate the underlying slices for better performance. +func NewQueryResultBuilder(capacity int) *QueryResultBuilder { + frame := data.NewFrame("states") + lbls := data.Labels(map[string]string{}) + + // We represent state history as a single merged history, that roughly corresponds to what you get in the Grafana Explore tab when querying Loki directly. + // The format is composed of the following vectors: + // 1. `time` - timestamp - when the transition happened + // 2. `line` - JSON - the full data of the transition + // 3. `labels` - JSON - the labels associated with that state transition + times := make([]time.Time, 0, capacity) + lines := make([]json.RawMessage, 0, capacity) + labels := make([]json.RawMessage, 0, capacity) + + frame.Fields = append(frame.Fields, data.NewField(dfTime, lbls, times)) + frame.Fields = append(frame.Fields, data.NewField(dfLine, lbls, lines)) + frame.Fields = append(frame.Fields, data.NewField(dfLabels, lbls, labels)) + + return &QueryResultBuilder{frame: frame} +} + +func (qr QueryResultBuilder) AddRowRaw(timestamp time.Time, line json.RawMessage, labels json.RawMessage) { + frame := qr.frame + frame.Fields[0].Append(timestamp) + frame.Fields[1].Append(line) + frame.Fields[2].Append(labels) +} + +func (qr QueryResultBuilder) AddRow(timestamp time.Time, line LokiEntry, labels json.RawMessage) error { + lineBytes, err := json.Marshal(line) + if err != nil { + return err + } + qr.AddRowRaw(timestamp, lineBytes, labels) + return nil +} + +// ToFrame converts the QueryResultBuilder back to a data.Frame. +func (qr QueryResultBuilder) ToFrame() *data.Frame { + return qr.frame +} + +func (qr QueryResultBuilder) AddWarn(s string) { + m := qr.frame.Meta + if m == nil { + m = &data.FrameMeta{} + qr.frame.SetMeta(m) + } + m.Notices = append(m.Notices, data.Notice{ + Severity: data.NoticeSeverityWarning, + Text: s, + Link: "", + Inspect: 0, + }) +} + const ( StateHistoryLabelKey = "from" StateHistoryLabelValue = "state-history" @@ -191,20 +254,7 @@ func (h RemoteLokiBackend) merge(res []lokiclient.Stream, folderUIDToFilter []st totalLen += len(arr.Values) } - // Create a new slice to store the merged elements. - frame := data.NewFrame("states") - - // We merge all series into a single linear history. - lbls := data.Labels(map[string]string{}) - - // We represent state history as a single merged history, that roughly corresponds to what you get in the Grafana Explore tab when querying Loki directly. - // The format is composed of the following vectors: - // 1. `time` - timestamp - when the transition happened - // 2. `line` - JSON - the full data of the transition - // 3. `labels` - JSON - the labels associated with that state transition - times := make([]time.Time, 0, totalLen) - lines := make([]json.RawMessage, 0, totalLen) - labels := make([]json.RawMessage, 0, totalLen) + queryResult := NewQueryResultBuilder(totalLen) // Initialize a slice of pointers to the current position in each array. pointers := make([]int, len(res)) @@ -259,17 +309,10 @@ func (h RemoteLokiBackend) merge(res []lokiclient.Stream, folderUIDToFilter []st pointers[minElStreamIdx]++ continue } - times = append(times, time.Unix(0, tsNano)) - labels = append(labels, lblsJson) - lines = append(lines, json.RawMessage(entryBytes)) + queryResult.AddRowRaw(time.Unix(0, tsNano), entryBytes, lblsJson) pointers[minElStreamIdx]++ } - - frame.Fields = append(frame.Fields, data.NewField(dfTime, lbls, times)) - frame.Fields = append(frame.Fields, data.NewField(dfLine, lbls, lines)) - frame.Fields = append(frame.Fields, data.NewField(dfLabels, lbls, labels)) - - return frame, nil + return queryResult.ToFrame(), nil } func StatesToStream(rule history_model.RuleMeta, states []state.StateTransition, externalLabels map[string]string, logger log.Logger) lokiclient.Stream { @@ -282,28 +325,11 @@ func StatesToStream(rule history_model.RuleMeta, states []state.StateTransition, samples := make([]lokiclient.Sample, 0, len(states)) for _, state := range states { - if !shouldRecord(state) { + if !ShouldRecord(state) { continue } - sanitizedLabels := removePrivateLabels(state.Labels) - entry := LokiEntry{ - SchemaVersion: 1, - Previous: state.PreviousFormatted(), - Current: state.Formatted(), - Values: valuesAsDataBlob(state.State), - Condition: rule.Condition, - DashboardUID: rule.DashboardUID, - PanelID: rule.PanelID, - Fingerprint: labelFingerprint(sanitizedLabels), - RuleTitle: rule.Title, - RuleID: rule.ID, - RuleUID: rule.UID, - InstanceLabels: sanitizedLabels, - } - if state.State.State == eval.Error { - entry.Error = state.Error.Error() - } + entry := StateTransitionToLokiEntry(rule, state) jsn, err := json.Marshal(entry) if err != nil { @@ -324,6 +350,28 @@ func StatesToStream(rule history_model.RuleMeta, states []state.StateTransition, } } +func StateTransitionToLokiEntry(rule history_model.RuleMeta, state state.StateTransition) LokiEntry { + sanitizedLabels := removePrivateLabels(state.Labels) + entry := LokiEntry{ + SchemaVersion: 1, + Previous: state.PreviousFormatted(), + Current: state.Formatted(), + Values: valuesAsDataBlob(state.State), + Condition: rule.Condition, + DashboardUID: rule.DashboardUID, + PanelID: rule.PanelID, + Fingerprint: labelFingerprint(sanitizedLabels), + RuleTitle: rule.Title, + RuleID: rule.ID, + RuleUID: rule.UID, + InstanceLabels: sanitizedLabels, + } + if state.State.State == eval.Error && state.Error != nil { + entry.Error = state.Error.Error() + } + return entry +} + func (h *RemoteLokiBackend) recordStreams(ctx context.Context, stream lokiclient.Stream, logger log.Logger) error { if err := h.client.Push(ctx, []lokiclient.Stream{stream}); err != nil { return err diff --git a/pkg/setting/setting_unified_alerting.go b/pkg/setting/setting_unified_alerting.go index 743f386ff52..6abaef8bc2e 100644 --- a/pkg/setting/setting_unified_alerting.go +++ b/pkg/setting/setting_unified_alerting.go @@ -156,6 +156,8 @@ type UnifiedAlertingSettings struct { // AlertmanagerMaxTemplateOutputSize specifies the maximum allowed size for rendered template output in bytes. AlertmanagerMaxTemplateOutputSize int64 + + BacktestingMaxEvaluations int } type RecordingRuleSettings struct { @@ -594,6 +596,11 @@ func (cfg *Cfg) ReadUnifiedAlertingSettings(iniFile *ini.File) error { return fmt.Errorf("setting 'alertmanager_max_template_output_bytes' is invalid, only 0 or a positive integer are allowed") } + uaCfg.BacktestingMaxEvaluations = ua.Key("backtesting_max_evaluations").MustInt(100) + if uaCfg.BacktestingMaxEvaluations < 0 { + uaCfg.BacktestingMaxEvaluations = 100 + } + cfg.UnifiedAlerting = uaCfg return nil } diff --git a/pkg/tests/api/alerting/api_backtesting_test.go b/pkg/tests/api/alerting/api_backtesting_test.go index f07be49ff01..faf50a60da2 100644 --- a/pkg/tests/api/alerting/api_backtesting_test.go +++ b/pkg/tests/api/alerting/api_backtesting_test.go @@ -68,7 +68,7 @@ func TestBacktesting(t *testing.T) { require.Truef(t, ok, "The data file does not contain a field `data`") status, body := apiCli.SubmitRuleForBacktesting(t, request) - require.Equal(t, http.StatusOK, status) + require.Equalf(t, http.StatusOK, status, "Response: %s", body) var result data.Frame require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame") }) @@ -107,6 +107,7 @@ func TestBacktesting(t *testing.T) { resourcepermissions.SetResourcePermissionCommand{ Actions: []string{ accesscontrol.ActionAlertingRuleRead, + accesscontrol.ActionAlertingRuleUpdate, }, Resource: "folders", ResourceID: "*", diff --git a/pkg/tests/api/alerting/test-data/api_backtesting_data.json b/pkg/tests/api/alerting/test-data/api_backtesting_data.json index d02b6905f0b..5fe0f621126 100644 --- a/pkg/tests/api/alerting/test-data/api_backtesting_data.json +++ b/pkg/tests/api/alerting/test-data/api_backtesting_data.json @@ -12,6 +12,9 @@ }, "condition": "A", "no_data_state": "Alerting", + "title": "test-rule-backtesting-data", + "rule_group": "test-group", + "namespace_uid": "test-namespace", "data": [ { "refId": "A", @@ -193,6 +196,9 @@ }, "condition": "C", "no_data_state": "Alerting", + "title": "test-rule-backtesting-data", + "rule_group": "test-group", + "namespace_uid": "test-namespace", "data": [ { "refId": "A", diff --git a/public/app/features/alerting/unified/api/backtestApi.ts b/public/app/features/alerting/unified/api/backtestApi.ts new file mode 100644 index 00000000000..14a0827cb20 --- /dev/null +++ b/public/app/features/alerting/unified/api/backtestApi.ts @@ -0,0 +1,50 @@ +import { DataFrameJSON } from '@grafana/data'; +import { AlertQuery, GrafanaAlertStateDecision, Labels } from 'app/types/unified-alerting-dto'; + +import { alertingApi } from './alertingApi'; + +/** + * Request body for the backtest API matching the BacktestConfig struct in the backend + */ +export interface BacktestRequest { + // Required time range fields + from: string; // ISO 8601 timestamp + to: string; // ISO 8601 timestamp + interval: string; // e.g., "1m", "5m" + + // Required alert definition fields + condition: string; + data: AlertQuery[]; + title: string; + no_data_state?: GrafanaAlertStateDecision; + exec_err_state?: GrafanaAlertStateDecision; + + // Optional duration fields + for?: string; + keep_firing_for?: string; + + // Optional metadata fields + labels?: Labels; + missing_series_evals_to_resolve?: number; + + // Optional rule identification fields + uid?: string; + rule_group?: string; + namespace_uid?: string; +} + +export const BACKTEST_URL = '/api/v1/rule/backtest'; + +export const backtestApi = alertingApi.injectEndpoints({ + endpoints: (build) => ({ + runBacktest: build.mutation({ + query: (requestBody) => ({ + url: BACKTEST_URL, + method: 'POST', + body: requestBody, + }), + }), + }), +}); + +export const { useRunBacktestMutation } = backtestApi; diff --git a/public/app/features/alerting/unified/components/backtesting/BacktestDropdownButton.tsx b/public/app/features/alerting/unified/components/backtesting/BacktestDropdownButton.tsx new file mode 100644 index 00000000000..7d7c4467780 --- /dev/null +++ b/public/app/features/alerting/unified/components/backtesting/BacktestDropdownButton.tsx @@ -0,0 +1,63 @@ +import { useCallback, useState } from 'react'; + +import { TimeRange, rangeUtil } from '@grafana/data'; +import { Trans, t } from '@grafana/i18n'; +import { Button, Drawer, Dropdown, Menu, MenuItem } from '@grafana/ui'; + +import { RuleFormValues } from '../../types/rule-form'; + +import { BacktestPanel } from './BacktestPanel'; + +interface BacktestDropdownButtonProps { + ruleDefinition: RuleFormValues; +} + +export function BacktestDropdownButton({ ruleDefinition }: BacktestDropdownButtonProps) { + const [isBacktestPanelOpen, setIsBacktestPanelOpen] = useState(false); + const [backtestTimeRange, setBacktestTimeRange] = useState(); + + const handleTimeRangeSelect = useCallback((rawFrom: string) => { + const timeRange = rangeUtil.convertRawToRange({ from: rawFrom, to: 'now' }); + setBacktestTimeRange(timeRange); + setIsBacktestPanelOpen(true); + }, []); + + const handleCustomSelect = useCallback(() => { + setBacktestTimeRange(undefined); + setIsBacktestPanelOpen(true); + }, []); + + return ( + <> + + handleTimeRangeSelect('now-15m')} + /> + handleTimeRangeSelect('now-1h')} + /> + + + } + > + + + + {isBacktestPanelOpen && ( + setIsBacktestPanelOpen(false)} + size="md" + > + + + )} + + ); +} diff --git a/public/app/features/alerting/unified/components/backtesting/BacktestPanel.tsx b/public/app/features/alerting/unified/components/backtesting/BacktestPanel.tsx new file mode 100644 index 00000000000..1a3a4ae7706 --- /dev/null +++ b/public/app/features/alerting/unified/components/backtesting/BacktestPanel.tsx @@ -0,0 +1,200 @@ +import { css } from '@emotion/css'; +import { fromPairs, isEmpty, isEqual } from 'lodash'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { AlertLabels } from '@grafana/alerting/unstable'; +import { DataFrameJSON, GrafanaTheme2, TimeRange, rangeUtil } from '@grafana/data'; +import { Trans, t } from '@grafana/i18n'; +import { + Alert, + Icon, + LoadingPlaceholder, + RefreshPicker, + Stack, + Text, + TimeRangePicker, + Tooltip, + useStyles2, +} from '@grafana/ui'; + +import { useRunBacktestMutation } from '../../api/backtestApi'; +import { RuleFormValues } from '../../types/rule-form'; +import { combineMatcherStrings } from '../../utils/alertmanager'; +import { messageFromError } from '../../utils/redux'; +import { formValuesToRulerGrafanaRuleDTO } from '../../utils/rule-form'; +import { LogRecordViewerByTimestamp } from '../rules/state-history/LogRecordViewer'; +import { LogTimelineViewer } from '../rules/state-history/LogTimelineViewer'; +import { useFrameSubset } from '../rules/state-history/LokiStateHistory'; +import { useRuleHistoryRecords } from '../rules/state-history/useRuleHistoryRecords'; + +interface BacktestPanelProps { + ruleDefinition: RuleFormValues; + initialTimeRange?: TimeRange; +} + +export function BacktestPanel({ ruleDefinition, initialTimeRange }: BacktestPanelProps) { + const styles = useStyles2(getStyles); + const [timeRange, setTimeRange] = useState( + initialTimeRange || rangeUtil.convertRawToRange({ from: 'now-15m', to: 'now' }) + ); + const [stateHistory, setStateHistory] = useState(); + const [instancesFilter, setInstancesFilter] = useState(''); + const shouldRunInitialBacktest = useRef(!!initialTimeRange); + + const [runBacktest, { isLoading, error: mutationError }] = useRunBacktestMutation(); + + const handleRunBacktest = useCallback(async () => { + // Convert form values to the proper AlertRule format + const alertRule = formValuesToRulerGrafanaRuleDTO(ruleDefinition); + + // Build requestBody matching BacktestConfig struct + const requestBody = { + // Required time range fields + from: timeRange.from.toISOString(), + to: timeRange.to.toISOString(), + interval: ruleDefinition.evaluateEvery, + + // Required alert definition fields + condition: alertRule.grafana_alert.condition, + data: alertRule.grafana_alert.data, + title: alertRule.grafana_alert.title, + no_data_state: alertRule.grafana_alert.no_data_state, + exec_err_state: alertRule.grafana_alert.exec_err_state, + + // Optional duration fields + for: alertRule.for, + keep_firing_for: alertRule.keep_firing_for, + + // Optional metadata fields + labels: alertRule.labels, + missing_series_evals_to_resolve: alertRule.grafana_alert.missing_series_evals_to_resolve, + + // Optional rule identification fields + uid: alertRule.grafana_alert.uid, + rule_group: ruleDefinition.group, + namespace_uid: ruleDefinition.folder?.uid, + }; + + try { + const result = await runBacktest(requestBody).unwrap(); + setStateHistory(result); + } catch (err) { + // Error is handled by RTK Query and available via mutationError + } + }, [ruleDefinition, timeRange, runBacktest]); + + // Update time range when initialTimeRange prop changes + useEffect(() => { + if (initialTimeRange) { + setTimeRange(initialTimeRange); + } + }, [initialTimeRange]); + + // Run backtest once after initial mount when timeRange is synchronized with initialTimeRange + useEffect(() => { + if (shouldRunInitialBacktest.current && initialTimeRange && isEqual(timeRange, initialTimeRange)) { + shouldRunInitialBacktest.current = false; + handleRunBacktest(); + } + }, [initialTimeRange, timeRange, handleRunBacktest]); + + const { dataFrames, historyRecords, commonLabels } = useRuleHistoryRecords(stateHistory, instancesFilter); + + const { frameSubset, frameTimeRange } = useFrameSubset(dataFrames); + + const onLogRecordLabelClick = useCallback( + (label: string) => { + const matcherString = combineMatcherStrings(instancesFilter, label); + setInstancesFilter(matcherString); + }, + [instancesFilter] + ); + + const hasResults = stateHistory !== undefined; + + const notices = stateHistory?.schema?.meta?.notices || []; + const errorMessage = mutationError ? messageFromError(mutationError) : null; + + return ( +
+ + {}} + onMoveBackward={() => {}} + onMoveForward={() => {}} + onZoom={() => {}} + /> + {}} + isLoading={isLoading} + noIntervalPicker={true} + /> + +
+ {isLoading && } + + {errorMessage && ( + {errorMessage} + )} + + {!isLoading && !mutationError && hasResults && notices.length > 0 && ( + + {notices.map((notice, index) => ( + + {notice.text} + + ))} + + )} + + {!isLoading && !mutationError && hasResults && ( +
+ {!isEmpty(commonLabels) && ( + + + + Common labels + + + + + + + + )} + + +
+ )} +
+
+ ); +} +const getStyles = (theme: GrafanaTheme2) => ({ + scrollableContent: css({ + flex: 1, + display: 'flex', + flexDirection: 'column', + paddingTop: theme.spacing(2), + overflow: 'hidden', + }), + resultsContainer: css({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + flex: 1, + overflow: 'hidden', + }), +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx index 85e72cca2d8..ed05cd8823c 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx @@ -60,6 +60,7 @@ import { formValuesToRulerRuleDTO, } from '../../../utils/rule-form'; import { fromRulerRule, fromRulerRuleAndRuleGroupIdentifier } from '../../../utils/rule-id'; +import { BacktestDropdownButton } from '../../backtesting/BacktestDropdownButton'; import { GrafanaRuleExporter } from '../../export/GrafanaRuleExporter'; import { AlertRuleNameAndMetric } from '../AlertRuleNameInput'; import AnnotationsStep from '../AnnotationsStep'; @@ -290,6 +291,8 @@ export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) => Edit YAML )} + + {config.featureToggles.alertingBacktesting && }
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 85563c43905..a51e48d0e7f 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "Enter a {{key}}...", "placeholder-value-input-default": "Enter custom annotation content..." }, + "backtest": { + "error-title": "Failed to run backtest", + "loading": "Running backtest...", + "panel-title": "Rule Retroactive Testing" + }, "bulk-actions": { "delete": { "success": "Rules successfully deleted from folder" @@ -2203,11 +2208,15 @@ "min-interval": "Min. Interval = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "Custom", "disableAdvancedOptions": { "text": "The selected queries and expressions cannot be converted to default. If you deactivate advanced options, your query and condition will be reset to default settings." }, + "last15m": "Last 15 minutes", + "last1h": "Last 1 hour", "preview": "Preview", - "previewCondition": "Preview alert rule condition" + "previewCondition": "Preview alert rule condition", + "testRule": "Test Rule" }, "receiver-filter": { "aria-label-contact-points": "Filter by contact points", From a345f78ae0faee08f0e51e9c04b46082ac3caec6 Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Sun, 28 Dec 2025 00:34:24 +0000 Subject: [PATCH 089/229] I18n: Download translations from Crowdin (#115717) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- public/locales/cs-CZ/grafana.json | 11 ++++++++++- public/locales/de-DE/grafana.json | 11 ++++++++++- public/locales/es-ES/grafana.json | 11 ++++++++++- public/locales/fr-FR/grafana.json | 11 ++++++++++- public/locales/hu-HU/grafana.json | 11 ++++++++++- public/locales/id-ID/grafana.json | 11 ++++++++++- public/locales/it-IT/grafana.json | 11 ++++++++++- public/locales/ja-JP/grafana.json | 11 ++++++++++- public/locales/ko-KR/grafana.json | 11 ++++++++++- public/locales/nl-NL/grafana.json | 11 ++++++++++- public/locales/pl-PL/grafana.json | 11 ++++++++++- public/locales/pt-BR/grafana.json | 11 ++++++++++- public/locales/pt-PT/grafana.json | 11 ++++++++++- public/locales/ru-RU/grafana.json | 11 ++++++++++- public/locales/sv-SE/grafana.json | 11 ++++++++++- public/locales/tr-TR/grafana.json | 11 ++++++++++- public/locales/zh-Hans/grafana.json | 11 ++++++++++- public/locales/zh-Hant/grafana.json | 11 ++++++++++- 18 files changed, 180 insertions(+), 18 deletions(-) diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index 2fb06738b36..7ed8c4bf808 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -729,6 +729,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Zadejte obsah vlastní vysvětlivky…" }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Pravidla byla úspěšně odstraněna ze složky" @@ -2219,11 +2224,15 @@ "min-interval": "Min. Interval = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "Vybrané dotazy a výrazy nelze převést na výchozí. Pokud deaktivujete pokročilé možnosti, váš dotaz a podmínka budou obnoveny do výchozího nastavení." }, + "last15m": "", + "last1h": "", "preview": "Náhled", - "previewCondition": "Podmínka pravidla náhledu výstrahy" + "previewCondition": "Podmínka pravidla náhledu výstrahy", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filtrovat podle kontaktních bodů", diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 6c09564ae2f..a8d01ea13bb 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Inhalt der benutzerdefinierten Anmerkung eingeben …" }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Die Regeln wurden erfolgreich aus dem Ordner gelöscht" @@ -2203,11 +2208,15 @@ "min-interval": "Mind. Intervall = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "Die ausgewählten Abfragen und Ausdrücke können nicht in die Standardeinstellung konvertiert werden. Wenn Sie die erweiterten Optionen deaktivieren, werden Ihre Abfrage und Bedingung auf die Standardeinstellungen zurückgesetzt." }, + "last15m": "", + "last1h": "", "preview": "Vorschau", - "previewCondition": "Vorschau der Warnregelbedingung" + "previewCondition": "Vorschau der Warnregelbedingung", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Nach Kontaktpunkten filtern", diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 45d955ba66c..a6f418cc1be 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Introduce el contenido de la anotación personalizada..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Reglas eliminadas correctamente de la carpeta" @@ -2203,11 +2208,15 @@ "min-interval": "Tamaño min. Intervalo = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "Las consultas y expresiones seleccionadas no se pueden convertir a predeterminadas. Si desactivas las opciones avanzadas, tu consulta y condición se restablecerán a la configuración predeterminada." }, + "last15m": "", + "last1h": "", "preview": "Vista previa", - "previewCondition": "Vista previa de la condición de la regla de alerta" + "previewCondition": "Vista previa de la condición de la regla de alerta", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filtrar por puntos de contacto", diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 33bf748fbc0..b6e586e10db 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Saisir le contenu de l’annotation personnalisée..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Règles supprimées du dossier" @@ -2203,11 +2208,15 @@ "min-interval": "Min. Intervalle = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "Les requêtes et expressions sélectionnées ne peuvent pas être converties en valeurs par défaut. Si vous désactivez les options avancées, votre requête et votre condition seront réinitialisées aux valeurs par défaut." }, + "last15m": "", + "last1h": "", "preview": "Aperçu", - "previewCondition": "Aperçu de la condition de la règle d'alerte" + "previewCondition": "Aperçu de la condition de la règle d'alerte", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filtrer par points de contact", diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index 3704f01de9a..d51785fdb8a 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Adja meg az egyéni jegyzet tartalmát…" }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "A szabályok sikeresen törlődtek a mappából" @@ -2203,11 +2208,15 @@ "min-interval": "Min. intervallum = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "A kijelölt lekérdezések és kifejezések nem konvertálhatók alapértelmezettre. Ha kikapcsolja a speciális beállításokat, a lekérdezés és a feltétel visszaáll az alapértelmezett beállításokra." }, + "last15m": "", + "last1h": "", "preview": "Előnézet", - "previewCondition": "Riasztási szabály előnézeti feltétele" + "previewCondition": "Riasztási szabály előnézeti feltétele", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Szűrés kapcsolattartási pontok szerint", diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index 602acd8813a..c000333c5c2 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -720,6 +720,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Masukkan konten anotasi kustom..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Aturan berhasil dihapus dari folder" @@ -2195,11 +2200,15 @@ "min-interval": "Min. Interval = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "Kueri dan ekspresi yang dipilih tidak dapat dikonversi ke default. Jika Anda menonaktifkan opsi lanjutan, kueri dan kondisi Anda akan diatur ulang ke pengaturan default." }, + "last15m": "", + "last1h": "", "preview": "Pratinjau", - "previewCondition": "Pratinjau kondisi aturan peringatan" + "previewCondition": "Pratinjau kondisi aturan peringatan", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filter berdasarkan titik kontak", diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index 4832c6c744c..5b5839ddf39 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Inserisci il contenuto dell'annotazione personalizzata..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Regole eliminate dalla cartella" @@ -2203,11 +2208,15 @@ "min-interval": "Min Intervallo = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "Le query e le espressioni selezionate non possono essere convertite in predefinite. Se disattivi le opzioni avanzate, la query e la condizione verranno ripristinate alle impostazioni predefinite." }, + "last15m": "", + "last1h": "", "preview": "Anteprima", - "previewCondition": "Anteprima condizione regola di avviso" + "previewCondition": "Anteprima condizione regola di avviso", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filtra per punti di contatto", diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index c87617b2161..85597e5cff8 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -720,6 +720,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "カスタム注釈内容を入力..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "ルールがフォルダから正常に削除されました" @@ -2195,11 +2200,15 @@ "min-interval": "最小間隔= {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "選択したクエリと式はデフォルトに変換できません。高度なオプションを無効にすると、クエリと条件はデフォルト設定にリセットされます。" }, + "last15m": "", + "last1h": "", "preview": "プレビュー", - "previewCondition": "アラートルール条件をプレビューする" + "previewCondition": "アラートルール条件をプレビューする", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "コンタクトポイントで絞り込む", diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index 65d967807ea..25e6bea87a4 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -720,6 +720,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "사용자 지정 주석 내용을 입력하세요..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "폴더에서 규칙이 성공적으로 삭제되었습니다" @@ -2195,11 +2200,15 @@ "min-interval": "최소 간격 = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "선택한 쿼리와 표현식을 기본값으로 변환할 수 없습니다. 고급 옵션을 비활성화하면 쿼리와 조건이 기본 설정으로 재설정됩니다." }, + "last15m": "", + "last1h": "", "preview": "미리보기", - "previewCondition": "경고 규칙 조건 미리보기" + "previewCondition": "경고 규칙 조건 미리보기", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "연락처로 필터링", diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index a1d9ba17c5b..b1f700e5957 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Aangepaste annotatie-inhoud invoeren..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Regels zijn verwijderd uit de map" @@ -2203,11 +2208,15 @@ "min-interval": "Min. Interval = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "De geselecteerde query's en expressies kunnen niet worden geconverteerd naar standaard. Als je geavanceerde opties deactiveert, worden je query en voorwaarde teruggezet naar de standaardinstellingen." }, + "last15m": "", + "last1h": "", "preview": "Voorbeeld", - "previewCondition": "Voorbeeld waarschuwingsregel voorwaarde" + "previewCondition": "Voorbeeld waarschuwingsregel voorwaarde", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filteren op contactpunten", diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index c7d04e0cd8b..2705b11c9ae 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -729,6 +729,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Wpisz treść niestandardowej adnotacji…" }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Reguły zostały usunięte z folderu" @@ -2219,11 +2224,15 @@ "min-interval": "Min. odstęp czasu = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "Nie można przekonwertować wybranych zapytań i wyrażeń na domyślne. Jeśli wyłączysz opcje zaawansowane, zapytanie i warunek zostaną zresetowane do ustawień domyślnych." }, + "last15m": "", + "last1h": "", "preview": "Podgląd", - "previewCondition": "Podgląd warunku reguły alertu" + "previewCondition": "Podgląd warunku reguły alertu", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filtruj według punktów kontaktu", diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index eee46fc8344..250376a959f 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Insira o conteúdo da anotação personalizada…" }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "As regras foram excluídas da pasta" @@ -2203,11 +2208,15 @@ "min-interval": "Mín. Intervalo = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "As consultas e expressões selecionadas não podem ser convertidas para o padrão. Se você desativar as opções avançadas, sua consulta e condição serão redefinidas para as configurações padrão." }, + "last15m": "", + "last1h": "", "preview": "Visualizar", - "previewCondition": "Visualizar condição de regra de alerta" + "previewCondition": "Visualizar condição de regra de alerta", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filtrar por pontos de contato", diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index 415075e65ab..beb5f7d3de8 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Introduzir o conteúdo da anotação personalizada..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Regras eliminadas da pasta com sucesso" @@ -2203,11 +2208,15 @@ "min-interval": "Min. Intervalo = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "As consultas e expressões selecionadas não podem ser convertidas para padrão. Se desativar as opções avançadas, a sua consulta e condição serão repostas para as definições padrão." }, + "last15m": "", + "last1h": "", "preview": "Pré-visualizar", - "previewCondition": "Pré-visualizar a condição da regra de alerta" + "previewCondition": "Pré-visualizar a condição da regra de alerta", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filtrar por pontos de contacto", diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index a8aab23f3d0..8655e70fba0 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -729,6 +729,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Ввести содержимое пользовательской аннотации..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Правила удалены из папки" @@ -2219,11 +2224,15 @@ "min-interval": "Мин. интервал = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "Выбранные запросы и выражения не могут быть преобразованы в используемые по умолчанию. Если вы отключите расширенные параметры, ваш запрос и условие будут сброшены до настроек по умолчанию." }, + "last15m": "", + "last1h": "", "preview": "Предварительный просмотр", - "previewCondition": "Предварительный просмотр условия правила оповещения" + "previewCondition": "Предварительный просмотр условия правила оповещения", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Фильтр по точкам контакта", diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index f3c4effc8b3..4869152e5e8 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Ange innehåll för anpassad kommentar …" }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Reglerna har raderats från mappen" @@ -2203,11 +2208,15 @@ "min-interval": "Min. Intervall = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "De valda frågorna och uttrycken kan inte konverteras till standard. Om du inaktiverar avancerade alternativ kommer din fråga och ditt villkor att återställas till standardinställningarna." }, + "last15m": "", + "last1h": "", "preview": "Förhandsgranska", - "previewCondition": "Förhandsgranska varningsregeltillstånd" + "previewCondition": "Förhandsgranska varningsregeltillstånd", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filtrera efter kontaktpunkter", diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index 7cd8b7b5939..ad54e0fd3e8 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Özel ek açıklama içeriği girin..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Kurallar klasörden başarıyla silindi" @@ -2203,11 +2208,15 @@ "min-interval": "Min. Aralık = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "Seçilen sorgular ve ifadeler varsayılana dönüştürülemez. Gelişmiş seçenekleri devre dışı bırakırsanız sorgunuz ve koşulunuz varsayılan ayarlara sıfırlanır." }, + "last15m": "", + "last1h": "", "preview": "Ön izleme", - "previewCondition": "Uyarı kuralı koşulunu ön izle" + "previewCondition": "Uyarı kuralı koşulunu ön izle", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "", diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index b36e525f676..7a4b67ba8e7 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -720,6 +720,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "输入自定义注释内容..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "规则已成功从文件夹中删除" @@ -2195,11 +2200,15 @@ "min-interval": "最小间隔 = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "无法将所选查询和表达式转换为默认值。如果停用高级选项,您的查询和条件将重置为默认设置。" }, + "last15m": "", + "last1h": "", "preview": "预览", - "previewCondition": "预览提醒规则条件" + "previewCondition": "预览提醒规则条件", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "按联络点筛选", diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index 0302a7ffb6f..5e3786ac559 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -720,6 +720,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "輸入自訂註解內容…" }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "已成功從資料夾中刪除規則" @@ -2195,11 +2200,15 @@ "min-interval": "最小間隔 = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "所選查詢和表達式無法轉換為預設值。如果停用進階選項,您的查詢和條件將重設為預設設定。" }, + "last15m": "", + "last1h": "", "preview": "預覽", - "previewCondition": "預覽警報規則條件" + "previewCondition": "預覽警報規則條件", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "按聯絡點篩選", From 4ba2fe6cce816da9c98d26ba473fda48261f897a Mon Sep 17 00:00:00 2001 From: Matheus Macabu Date: Mon, 29 Dec 2025 09:31:58 +0100 Subject: [PATCH 090/229] Auditing: Add Event struct to map audit logs into (#115509) --- pkg/apiserver/auditing/event.go | 88 ++++++++++++++++++++++++++++ pkg/apiserver/auditing/event_test.go | 64 ++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 pkg/apiserver/auditing/event.go create mode 100644 pkg/apiserver/auditing/event_test.go diff --git a/pkg/apiserver/auditing/event.go b/pkg/apiserver/auditing/event.go new file mode 100644 index 00000000000..dc5829096e6 --- /dev/null +++ b/pkg/apiserver/auditing/event.go @@ -0,0 +1,88 @@ +package auditing + +import ( + "encoding/json" + "time" +) + +type Event struct { + // The namespace the action was performed in. + Namespace string `json:"namespace"` + + // When it happened. + ObservedAt time.Time `json:"-"` // see MarshalJSON for why this is omitted + + // Who/what performed the action. + SubjectName string `json:"subjectName"` + SubjectUID string `json:"subjectUID"` + + // What was performed. + Verb string `json:"verb"` + + // The object the action was performed on. For verbs like "list" this will be empty. + Object string `json:"object,omitempty"` + + // API information. + APIGroup string `json:"apiGroup,omitempty"` + APIVersion string `json:"apiVersion,omitempty"` + Kind string `json:"kind,omitempty"` + + // Outcome of the action. + Outcome EventOutcome `json:"outcome"` + + // Extra fields to add more context to the event. + Extra map[string]string `json:"extra,omitempty"` +} + +func (e Event) Time() time.Time { + return e.ObservedAt +} + +func (e Event) MarshalJSON() ([]byte, error) { + type Alias Event + return json.Marshal(&struct { + FormattedTimestamp string `json:"observedAt"` + Alias + }{ + FormattedTimestamp: e.ObservedAt.UTC().Format(time.RFC3339Nano), + Alias: (Alias)(e), + }) +} + +func (e Event) KVPairs() []any { + args := []any{ + "audit", true, + "namespace", e.Namespace, + "observedAt", e.ObservedAt.UTC().Format(time.RFC3339Nano), + "subjectName", e.SubjectName, + "subjectUID", e.SubjectUID, + "verb", e.Verb, + "object", e.Object, + "apiGroup", e.APIGroup, + "apiVersion", e.APIVersion, + "kind", e.Kind, + "outcome", e.Outcome, + } + + if len(e.Extra) > 0 { + extraArgs := make([]any, 0, len(e.Extra)*2) + + for k, v := range e.Extra { + extraArgs = append(extraArgs, "extra_"+k, v) + } + + args = append(args, extraArgs...) + } + + return args +} + +type EventOutcome string + +const ( + EventOutcomeUnknown EventOutcome = "unknown" + EventOutcomeSuccess EventOutcome = "success" + EventOutcomeFailureUnauthorized EventOutcome = "failure_unauthorized" + EventOutcomeFailureNotFound EventOutcome = "failure_not_found" + EventOutcomeFailureGeneric EventOutcome = "failure_generic" +) diff --git a/pkg/apiserver/auditing/event_test.go b/pkg/apiserver/auditing/event_test.go new file mode 100644 index 00000000000..3267936b02a --- /dev/null +++ b/pkg/apiserver/auditing/event_test.go @@ -0,0 +1,64 @@ +package auditing_test + +import ( + "encoding/json" + "strconv" + "strings" + "testing" + "time" + + "github.com/grafana/grafana/pkg/apiserver/auditing" + "github.com/stretchr/testify/require" +) + +func TestEvent_MarshalJSON(t *testing.T) { + t.Parallel() + + t.Run("marshals the event", func(t *testing.T) { + t.Parallel() + + now := time.Now() + + event := auditing.Event{ + ObservedAt: now, + Extra: map[string]string{"k1": "v1", "k2": "v2"}, + } + + data, err := json.Marshal(event) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(data, &result)) + + require.Equal(t, event.Time().UTC().Format(time.RFC3339Nano), result["observedAt"]) + require.NotNil(t, result["extra"]) + require.Len(t, result["extra"], 2) + }) +} + +func TestEvent_KVPairs(t *testing.T) { + t.Parallel() + + t.Run("records extra fields", func(t *testing.T) { + t.Parallel() + + extraFields := 2 + extra := make(map[string]string, 0) + for i := 0; i < extraFields; i++ { + extra[strconv.Itoa(i)] = "value" + } + + event := auditing.Event{Extra: extra} + + kvPairs := event.KVPairs() + + extraCount := 0 + for i := 0; i < len(kvPairs); i += 2 { + if strings.HasPrefix(kvPairs[i].(string), "extra_") { + extraCount++ + } + } + + require.Equal(t, extraCount, extraFields) + }) +} From 0b58cd3900f721224f616b572aadb424654c6eca Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Mon, 29 Dec 2025 09:53:45 +0100 Subject: [PATCH 091/229] Dashboard: Remove BOMs from links during conversion (#115689) * Dashboard: Add test case for BOM characters in link URLs This test demonstrates the issue where BOM (Byte Order Mark) characters in dashboard link URLs cause CUE validation errors during v1 to v2 conversion ('illegal byte order mark'). The test input contains BOMs in various URL locations: - Dashboard links - Panel data links - Field config override links - Options dataLinks - Field config default links * Dashboard: Strip BOM characters from URLs during v1 to v2 conversion BOM (Byte Order Mark) characters in dashboard link URLs cause CUE validation errors ('illegal byte order mark') when opening v2 dashboards. This fix strips BOMs from all URL fields during conversion: - Dashboard links - Panel data links - Field config override links - Options dataLinks - Field config default links The stripBOM helper recursively processes nested structures to ensure all string values have BOMs removed. * Dashboard: Strip BOM characters in frontend v2 conversion Add stripBOMs parameter to sortedDeepCloneWithoutNulls utility to remove Byte Order Mark (U+FEFF) characters from all strings when serializing dashboards to v2 format. This prevents CUE validation errors ('illegal byte order mark') that occur when BOMs are present in any string field. BOMs can be introduced through copy/paste from certain editors or text sources. Applied at the final serialization step so it catches BOMs from: - Existing v1 dashboards being converted - New data entered during dashboard editing --- .../testdata/input/v1beta1.bom-in-links.json | 142 ++++++++++ ...estdata-nested-variables.v42.v2alpha1.json | 2 +- ...testdata-nested-variables.v42.v2beta1.json | 2 +- .../v0alpha1.gauge_tests_new.v42.v1beta1.json | 2 +- ...v0alpha1.gauge_tests_new.v42.v2alpha1.json | 2 +- .../v0alpha1.gauge_tests_new.v42.v2beta1.json | 2 +- ...a1.gauge_tests_old_to_new.v42.v1beta1.json | 2 +- ...1.gauge_tests_old_to_new.v42.v2alpha1.json | 2 +- ...a1.gauge_tests_old_to_new.v42.v2beta1.json | 2 +- .../output/v1beta1.bom-in-links.v0alpha1.json | 161 ++++++++++++ .../output/v1beta1.bom-in-links.v2alpha1.json | 242 +++++++++++++++++ .../output/v1beta1.bom-in-links.v2beta1.json | 246 ++++++++++++++++++ .../conversion/v1beta1_to_v2alpha1.go | 54 +++- public/app/core/utils/object.ts | 16 +- .../transformSceneToSaveModelSchemaV2.ts | 3 +- 15 files changed, 861 insertions(+), 19 deletions(-) create mode 100644 apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.bom-in-links.json create mode 100644 apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v0alpha1.json create mode 100644 apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v2alpha1.json create mode 100644 apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v2beta1.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.bom-in-links.json b/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.bom-in-links.json new file mode 100644 index 00000000000..86992c3380c --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.bom-in-links.json @@ -0,0 +1,142 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v1beta1", + "metadata": { + "name": "bom-in-links-test", + "namespace": "org-1", + "labels": { + "test": "bom-stripping" + } + }, + "spec": { + "title": "BOM Stripping Test Dashboard", + "description": "Testing that BOM characters are stripped from URLs during conversion", + "schemaVersion": 42, + "tags": ["test", "bom"], + "editable": true, + "links": [ + { + "title": "Dashboard link with BOM", + "type": "link", + "url": "http://example.com?var=${datasource}&other=value", + "targetBlank": true, + "icon": "external link" + } + ], + "panels": [ + { + "id": 1, + "type": "table", + "title": "Panel with BOM in field config override links", + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "fieldConfig": { + "defaults": { + "custom": {}, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "green"}, + {"color": "red", "value": 80} + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "server" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Override link with BOM", + "url": "http://localhost:3000/d/test?var-datacenter=${__data.fields[datacenter]}&var-server=${__value.raw}" + } + ] + } + ] + } + ] + }, + "links": [ + { + "title": "Panel data link with BOM", + "url": "http://example.com/${__data.fields.cluster}&var=value", + "targetBlank": true + } + ], + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "test-ds" + } + } + ] + }, + { + "id": 2, + "type": "timeseries", + "title": "Panel with BOM in options dataLinks", + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "options": { + "legend": { + "showLegend": true, + "displayMode": "list", + "placement": "bottom" + }, + "dataLinks": [ + { + "title": "Options data link with BOM", + "url": "http://example.com?series=${__series.name}&time=${__value.time}", + "targetBlank": true + } + ] + }, + "fieldConfig": { + "defaults": { + "links": [ + { + "title": "Field config default link with BOM", + "url": "http://example.com?field=${__field.name}&value=${__value.raw}", + "targetBlank": false + } + ] + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "test-ds" + } + } + ] + } + ], + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m"] + } + } +} + diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2alpha1.json index 89857905689..b1dbd3de041 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2alpha1.json @@ -120,7 +120,7 @@ "value": [ { "title": "filter", - "url": "http://localhost:3000/d/-Y-tnEDWk/templating-nested-template-variables?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}" + "url": "http://localhost:3000/d/-Y-tnEDWk/templating-nested-template-variables?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}" } ] } diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2beta1.json index 13320b47904..9089dd1d1fb 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2beta1.json @@ -124,7 +124,7 @@ "value": [ { "title": "filter", - "url": "http://localhost:3000/d/-Y-tnEDWk/templating-nested-template-variables?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}" + "url": "http://localhost:3000/d/-Y-tnEDWk/templating-nested-template-variables?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}" } ] } diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v1beta1.json index e04d448a5b8..66ce1cd0f3a 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v1beta1.json @@ -2051,4 +2051,4 @@ "storedVersion": "v0alpha1" } } -} +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2alpha1.json index 0e6e3e13da5..95850646c59 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2alpha1.json @@ -2691,4 +2691,4 @@ "storedVersion": "v0alpha1" } } -} +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2beta1.json index ad2b8ca0385..fda0d31e71b 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2beta1.json @@ -2764,4 +2764,4 @@ "storedVersion": "v0alpha1" } } -} +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v1beta1.json index 1d9f7e56513..2dddd657c5f 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v1beta1.json @@ -1173,4 +1173,4 @@ "storedVersion": "v0alpha1" } } -} +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2alpha1.json index 7b3f601b5cf..db19ac588c1 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2alpha1.json @@ -1618,4 +1618,4 @@ "storedVersion": "v0alpha1" } } -} +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2beta1.json index 534e7a1600c..8ddc6feb297 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2beta1.json @@ -1670,4 +1670,4 @@ "storedVersion": "v0alpha1" } } -} +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v0alpha1.json new file mode 100644 index 00000000000..449e76f1173 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v0alpha1.json @@ -0,0 +1,161 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v0alpha1", + "metadata": { + "name": "bom-in-links-test", + "namespace": "org-1", + "labels": { + "test": "bom-stripping" + } + }, + "spec": { + "description": "Testing that BOM characters are stripped from URLs during conversion", + "editable": true, + "links": [ + { + "icon": "external link", + "targetBlank": true, + "title": "Dashboard link with BOM", + "type": "link", + "url": "http://example.com?var=${datasource}\u0026other=value" + } + ], + "panels": [ + { + "fieldConfig": { + "defaults": { + "custom": {}, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "server" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Override link with BOM", + "url": "http://localhost:3000/d/test?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}" + } + ] + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "links": [ + { + "targetBlank": true, + "title": "Panel data link with BOM", + "url": "http://example.com/${__data.fields.cluster}\u0026var=value" + } + ], + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "test-ds" + }, + "refId": "A" + } + ], + "title": "Panel with BOM in field config override links", + "type": "table" + }, + { + "fieldConfig": { + "defaults": { + "links": [ + { + "targetBlank": false, + "title": "Field config default link with BOM", + "url": "http://example.com?field=${__field.name}\u0026value=${__value.raw}" + } + ] + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "dataLinks": [ + { + "targetBlank": true, + "title": "Options data link with BOM", + "url": "http://example.com?series=${__series.name}\u0026time=${__value.time}" + } + ], + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "test-ds" + }, + "refId": "A" + } + ], + "title": "Panel with BOM in options dataLinks", + "type": "timeseries" + } + ], + "schemaVersion": 42, + "tags": [ + "test", + "bom" + ], + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m" + ] + }, + "title": "BOM Stripping Test Dashboard" + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v2alpha1.json new file mode 100644 index 00000000000..38547ea5b8e --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v2alpha1.json @@ -0,0 +1,242 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v2alpha1", + "metadata": { + "name": "bom-in-links-test", + "namespace": "org-1", + "labels": { + "test": "bom-stripping" + } + }, + "spec": { + "annotations": [], + "cursorSync": "Off", + "description": "Testing that BOM characters are stripped from URLs during conversion", + "editable": true, + "elements": { + "panel-1": { + "kind": "Panel", + "spec": { + "id": 1, + "title": "Panel with BOM in field config override links", + "description": "", + "links": [ + { + "title": "Panel data link with BOM", + "url": "http://example.com/${__data.fields.cluster}\u0026var=value", + "targetBlank": true + } + ], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "prometheus", + "spec": {} + }, + "datasource": { + "type": "prometheus", + "uid": "test-ds" + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "table", + "spec": { + "pluginVersion": "", + "options": {}, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "server" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Override link with BOM", + "url": "http://localhost:3000/d/test?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}" + } + ] + } + ] + } + ] + } + } + } + } + }, + "panel-2": { + "kind": "Panel", + "spec": { + "id": 2, + "title": "Panel with BOM in options dataLinks", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "prometheus", + "spec": {} + }, + "datasource": { + "type": "prometheus", + "uid": "test-ds" + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "timeseries", + "spec": { + "pluginVersion": "", + "options": { + "dataLinks": [ + { + "targetBlank": true, + "title": "Options data link with BOM", + "url": "http://example.com?series=${__series.name}\u0026time=${__value.time}" + } + ], + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + } + }, + "fieldConfig": { + "defaults": { + "links": [ + { + "targetBlank": false, + "title": "Field config default link with BOM", + "url": "http://example.com?field=${__field.name}\u0026value=${__value.raw}" + } + ] + }, + "overrides": [] + } + } + } + } + } + }, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 0, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-1" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 12, + "y": 0, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-2" + } + } + } + ] + } + }, + "links": [ + { + "title": "Dashboard link with BOM", + "type": "link", + "icon": "external link", + "tooltip": "", + "url": "http://example.com?var=${datasource}\u0026other=value", + "tags": [], + "asDropdown": false, + "targetBlank": true, + "includeVars": false, + "keepTime": false + } + ], + "liveNow": false, + "preload": false, + "tags": [ + "test", + "bom" + ], + "timeSettings": { + "timezone": "browser", + "from": "now-6h", + "to": "now", + "autoRefresh": "", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m" + ], + "hideTimepicker": false, + "fiscalYearStartMonth": 0 + }, + "title": "BOM Stripping Test Dashboard", + "variables": [] + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v2beta1.json new file mode 100644 index 00000000000..d85da89fe7a --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v2beta1.json @@ -0,0 +1,246 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v2beta1", + "metadata": { + "name": "bom-in-links-test", + "namespace": "org-1", + "labels": { + "test": "bom-stripping" + } + }, + "spec": { + "annotations": [], + "cursorSync": "Off", + "description": "Testing that BOM characters are stripped from URLs during conversion", + "editable": true, + "elements": { + "panel-1": { + "kind": "Panel", + "spec": { + "id": 1, + "title": "Panel with BOM in field config override links", + "description": "", + "links": [ + { + "title": "Panel data link with BOM", + "url": "http://example.com/${__data.fields.cluster}\u0026var=value", + "targetBlank": true + } + ], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "test-ds" + }, + "spec": {} + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "table", + "version": "", + "spec": { + "options": {}, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "server" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Override link with BOM", + "url": "http://localhost:3000/d/test?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}" + } + ] + } + ] + } + ] + } + } + } + } + }, + "panel-2": { + "kind": "Panel", + "spec": { + "id": 2, + "title": "Panel with BOM in options dataLinks", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "test-ds" + }, + "spec": {} + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "timeseries", + "version": "", + "spec": { + "options": { + "dataLinks": [ + { + "targetBlank": true, + "title": "Options data link with BOM", + "url": "http://example.com?series=${__series.name}\u0026time=${__value.time}" + } + ], + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + } + }, + "fieldConfig": { + "defaults": { + "links": [ + { + "targetBlank": false, + "title": "Field config default link with BOM", + "url": "http://example.com?field=${__field.name}\u0026value=${__value.raw}" + } + ] + }, + "overrides": [] + } + } + } + } + } + }, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 0, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-1" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 12, + "y": 0, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-2" + } + } + } + ] + } + }, + "links": [ + { + "title": "Dashboard link with BOM", + "type": "link", + "icon": "external link", + "tooltip": "", + "url": "http://example.com?var=${datasource}\u0026other=value", + "tags": [], + "asDropdown": false, + "targetBlank": true, + "includeVars": false, + "keepTime": false + } + ], + "liveNow": false, + "preload": false, + "tags": [ + "test", + "bom" + ], + "timeSettings": { + "timezone": "browser", + "from": "now-6h", + "to": "now", + "autoRefresh": "", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m" + ], + "hideTimepicker": false, + "fiscalYearStartMonth": 0 + }, + "title": "BOM Stripping Test Dashboard", + "variables": [] + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go index 224f222ae33..b63e0146cc2 100644 --- a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go +++ b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go @@ -229,6 +229,36 @@ func getBoolField(m map[string]interface{}, key string, defaultValue bool) bool return defaultValue } +// stripBOM removes Byte Order Mark (BOM) characters from a string. +// BOMs (U+FEFF) can be introduced through copy/paste from certain editors +// and cause CUE validation errors ("illegal byte order mark"). +func stripBOM(s string) string { + return strings.ReplaceAll(s, "\ufeff", "") +} + +// stripBOMFromInterface recursively strips BOM characters from all strings +// in an interface{} value (map, slice, or string). +func stripBOMFromInterface(v interface{}) interface{} { + switch val := v.(type) { + case string: + return stripBOM(val) + case map[string]interface{}: + result := make(map[string]interface{}, len(val)) + for k, v := range val { + result[k] = stripBOMFromInterface(v) + } + return result + case []interface{}: + result := make([]interface{}, len(val)) + for i, item := range val { + result[i] = stripBOMFromInterface(item) + } + return result + default: + return v + } +} + func getUnionField[T ~string](m map[string]interface{}, key string) *T { if val, ok := m[key]; ok { if str, ok := val.(string); ok && str != "" { @@ -393,7 +423,8 @@ func transformLinks(dashboard map[string]interface{}) []dashv2alpha1.DashboardDa // Optional field - only set if present if url, exists := linkMap["url"]; exists { if urlStr, ok := url.(string); ok { - dashLink.Url = &urlStr + cleanUrl := stripBOM(urlStr) + dashLink.Url = &cleanUrl } } @@ -2239,7 +2270,7 @@ func transformDataLinks(panelMap map[string]interface{}) []dashv2alpha1.Dashboar if linkMap, ok := link.(map[string]interface{}); ok { dataLink := dashv2alpha1.DashboardDataLink{ Title: schemaversion.GetStringValue(linkMap, "title"), - Url: schemaversion.GetStringValue(linkMap, "url"), + Url: stripBOM(schemaversion.GetStringValue(linkMap, "url")), } if _, exists := linkMap["targetBlank"]; exists { targetBlank := getBoolField(linkMap, "targetBlank", false) @@ -2331,6 +2362,12 @@ func buildVizConfig(panelMap map[string]interface{}) dashv2alpha1.DashboardVizCo } } + // Strip BOMs from options (may contain dataLinks with URLs that have BOMs) + cleanedOptions := stripBOMFromInterface(options) + if cleanedMap, ok := cleanedOptions.(map[string]interface{}); ok { + options = cleanedMap + } + // Build field config by mapping each field individually fieldConfigSource := extractFieldConfigSource(fieldConfig) @@ -2474,9 +2511,14 @@ func extractFieldConfigDefaults(defaults map[string]interface{}) dashv2alpha1.Da hasDefaults = true } - // Extract array field + // Extract array field - strip BOMs from link URLs if linksArray, ok := extractArrayField(defaults, "links"); ok { - fieldConfigDefaults.Links = linksArray + cleanedLinks := stripBOMFromInterface(linksArray) + if cleanedArray, ok := cleanedLinks.([]interface{}); ok { + fieldConfigDefaults.Links = cleanedArray + } else { + fieldConfigDefaults.Links = linksArray + } hasDefaults = true } @@ -2762,9 +2804,11 @@ func extractFieldConfigOverrides(fieldConfig map[string]interface{}) []dashv2alp fieldOverride.Properties = make([]dashv2alpha1.DashboardDynamicConfigValue, 0, len(propertiesArray)) for _, property := range propertiesArray { if propertyMap, ok := property.(map[string]interface{}); ok { + // Strip BOMs from property values (may contain links with URLs) + cleanedValue := stripBOMFromInterface(propertyMap["value"]) fieldOverride.Properties = append(fieldOverride.Properties, dashv2alpha1.DashboardDynamicConfigValue{ Id: schemaversion.GetStringValue(propertyMap, "id"), - Value: propertyMap["value"], + Value: cleanedValue, }) } } diff --git a/public/app/core/utils/object.ts b/public/app/core/utils/object.ts index 7ace78598c4..ba1426b163c 100644 --- a/public/app/core/utils/object.ts +++ b/public/app/core/utils/object.ts @@ -1,23 +1,29 @@ -import { isArray, isPlainObject } from 'lodash'; +import { isArray, isPlainObject, isString } from 'lodash'; /** * @returns A deep clone of the object, but with any null value removed. * @param value - The object to be cloned and cleaned. * @param convertInfinity - If true, -Infinity or Infinity is converted to 0. * This is because Infinity is not a valid JSON value, and sometimes we want to convert it to 0 instead of default null. + * @param stripBOMs - If true, strips Byte Order Mark (BOM) characters from all strings. + * BOMs (U+FEFF) can cause CUE validation errors ("illegal byte order mark"). */ -export function sortedDeepCloneWithoutNulls(value: T, convertInfinity?: boolean): T { +export function sortedDeepCloneWithoutNulls(value: T, convertInfinity?: boolean, stripBOMs?: boolean): T { if (isArray(value)) { - return value.map((item) => sortedDeepCloneWithoutNulls(item, convertInfinity)) as unknown as T; + return value.map((item) => sortedDeepCloneWithoutNulls(item, convertInfinity, stripBOMs)) as unknown as T; } if (isPlainObject(value)) { return Object.keys(value as { [key: string]: any }) .sort() .reduce((acc: any, key) => { - const v = (value as any)[key]; + let v = (value as any)[key]; // Remove null values if (v != null) { - acc[key] = sortedDeepCloneWithoutNulls(v, convertInfinity); + // Strip BOMs from strings + if (stripBOMs && isString(v)) { + v = v.replace(/\ufeff/g, ''); + } + acc[key] = sortedDeepCloneWithoutNulls(v, convertInfinity, stripBOMs); } if (convertInfinity && (v === Infinity || v === -Infinity)) { diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts index 0ef2a5e5f05..90c7f5e2e61 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts @@ -144,7 +144,8 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps try { // validateDashboardSchemaV2 will throw an error if the dashboard is not valid if (validateDashboardSchemaV2(dashboardSchemaV2)) { - return sortedDeepCloneWithoutNulls(dashboardSchemaV2, true); + // Strip BOMs from all strings to prevent CUE validation errors ("illegal byte order mark") + return sortedDeepCloneWithoutNulls(dashboardSchemaV2, true, true); } // should never reach this point, validation should throw an error throw new Error('Error we could transform the dashboard to schema v2: ' + dashboardSchemaV2); From 30ad61e0e9fe02c4846e56d0c43ca5bf66907762 Mon Sep 17 00:00:00 2001 From: Ivan Ortega Alba Date: Mon, 29 Dec 2025 10:29:50 +0100 Subject: [PATCH 092/229] Dashboards: Fix adhoc filter click when panel has no panel-level datasource (#115576) * V2: Panel datasource is defined only for mixed ds * if getDatasourceFromQueryRunner only returns ds.type, resolve to full ds ref throgh ds service --------- Co-authored-by: Haris Rozajac --- .../scene/setDashboardPanelContext.test.ts | 35 +++++++++++++++- .../scene/setDashboardPanelContext.ts | 41 ++++++++++++++++--- .../dashboard-scene/utils/drilldownUtils.ts | 4 +- .../dashboard-scene/utils/urlBuilders.ts | 5 ++- .../features/dashboard-scene/utils/utils.ts | 22 +++++++++- 5 files changed, 95 insertions(+), 12 deletions(-) diff --git a/public/app/features/dashboard-scene/scene/setDashboardPanelContext.test.ts b/public/app/features/dashboard-scene/scene/setDashboardPanelContext.test.ts index d5669497180..cf1968f45f7 100644 --- a/public/app/features/dashboard-scene/scene/setDashboardPanelContext.test.ts +++ b/public/app/features/dashboard-scene/scene/setDashboardPanelContext.test.ts @@ -1,10 +1,10 @@ import { AdHocVariableModel, EventBusSrv, GroupByVariableModel, VariableModel } from '@grafana/data'; import { BackendSrv, config, setBackendSrv } from '@grafana/runtime'; -import { GroupByVariable, sceneGraph } from '@grafana/scenes'; +import { GroupByVariable, sceneGraph, SceneQueryRunner } from '@grafana/scenes'; import { AdHocFilterItem, PanelContext } from '@grafana/ui'; import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; -import { findVizPanelByKey } from '../utils/utils'; +import { findVizPanelByKey, getQueryRunnerFor } from '../utils/utils'; import { getAdHocFilterVariableFor, setDashboardPanelContext } from './setDashboardPanelContext'; @@ -159,6 +159,23 @@ describe('setDashboardPanelContext', () => { // Verify existing filter value updated expect(variable.state.filters[1].operator).toBe('!='); }); + + it('Should use existing adhoc filter when panel has no panel-level datasource because queries have all the same datasources (v2 behavior)', () => { + const { scene, context } = buildTestScene({ existingFilterVariable: true, panelDatasourceUndefined: true }); + + const variable = getAdHocFilterVariableFor(scene, { uid: 'my-ds-uid' }); + variable.setState({ filters: [] }); + + context.onAddAdHocFilter!({ key: 'hello', value: 'world', operator: '=' }); + + // Should use the existing adhoc filter variable, not create a new one + expect(variable.state.filters).toEqual([{ key: 'hello', value: 'world', operator: '=' }]); + + // Verify no new adhoc variables were created + const variables = sceneGraph.getVariables(scene); + const adhocVars = variables.state.variables.filter((v) => v.state.type === 'adhoc'); + expect(adhocVars.length).toBe(1); + }); }); describe('getFiltersBasedOnGrouping', () => { @@ -312,6 +329,7 @@ interface SceneOptions { existingFilterVariable?: boolean; existingGroupByVariable?: boolean; groupByDatasourceUid?: string; + panelDatasourceUndefined?: boolean; } function buildTestScene(options: SceneOptions) { @@ -385,6 +403,19 @@ function buildTestScene(options: SceneOptions) { }); const vizPanel = findVizPanelByKey(scene, 'panel-4')!; + + // Simulate v2 dashboard behavior where non-mixed panels don't have panel-level datasource + // but the queries have their own datasources + if (options.panelDatasourceUndefined) { + const queryRunner = getQueryRunnerFor(vizPanel); + if (queryRunner instanceof SceneQueryRunner) { + queryRunner.setState({ + datasource: undefined, + queries: [{ refId: 'A', datasource: { uid: 'my-ds-uid', type: 'prometheus' } }], + }); + } + } + const context: PanelContext = { eventBus: new EventBusSrv(), eventsScope: 'global', diff --git a/public/app/features/dashboard-scene/scene/setDashboardPanelContext.ts b/public/app/features/dashboard-scene/scene/setDashboardPanelContext.ts index c9b3fdf44bd..a256a3305b1 100644 --- a/public/app/features/dashboard-scene/scene/setDashboardPanelContext.ts +++ b/public/app/features/dashboard-scene/scene/setDashboardPanelContext.ts @@ -6,7 +6,12 @@ import { AdHocFilterItem, PanelContext } from '@grafana/ui'; import { annotationServer } from 'app/features/annotations/api'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; -import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils'; +import { + getDashboardSceneFor, + getDatasourceFromQueryRunner, + getPanelIdForVizPanel, + getQueryRunnerFor, +} from '../utils/utils'; import { DashboardScene } from './DashboardScene'; @@ -121,7 +126,7 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte context.eventBus.publish(new AnnotationChangeEvent({ id })); }; - context.onAddAdHocFilter = (newFilter: AdHocFilterItem) => { + context.onAddAdHocFilter = async (newFilter: AdHocFilterItem) => { const dashboard = getDashboardSceneFor(vizPanel); const queryRunner = getQueryRunnerFor(vizPanel); @@ -129,7 +134,19 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte return; } - const filterVar = getAdHocFilterVariableFor(dashboard, queryRunner.state.datasource); + let datasource = getDatasourceFromQueryRunner(queryRunner); + + // If the datasource is type-only (e.g. it's possible that only group is set in V2 schema queries) + // we need to resolve it to a full datasource + if (datasource && !datasource.uid) { + const datasourceToLoad = await getDataSourceSrv().get(datasource); + datasource = { + uid: datasourceToLoad.uid, + type: datasourceToLoad.type, + }; + } + + const filterVar = getAdHocFilterVariableFor(dashboard, datasource); updateAdHocFilterVariable(filterVar, newFilter); }; @@ -141,7 +158,8 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte return []; } - const groupByVar = getGroupByVariableFor(dashboard, queryRunner.state.datasource); + const datasource = getDatasourceFromQueryRunner(queryRunner); + const groupByVar = getGroupByVariableFor(dashboard, datasource); if (!groupByVar) { return []; @@ -158,7 +176,7 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte .filter((item) => item !== undefined); }; - context.onAddAdHocFilters = (items: AdHocFilterItem[]) => { + context.onAddAdHocFilters = async (items: AdHocFilterItem[]) => { const dashboard = getDashboardSceneFor(vizPanel); const queryRunner = getQueryRunnerFor(vizPanel); @@ -166,7 +184,18 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte return; } - const filterVar = getAdHocFilterVariableFor(dashboard, queryRunner.state.datasource); + let datasource = getDatasourceFromQueryRunner(queryRunner); + + // If the datasource is type-only (e.g. it's possible that only group is set in V2 schema queries) + // we need to resolve it to a full datasource + if (datasource && !datasource.uid) { + const datasourceToLoad = await getDataSourceSrv().get(datasource); + datasource = { + uid: datasourceToLoad.uid, + type: datasourceToLoad.type, + }; + } + const filterVar = getAdHocFilterVariableFor(dashboard, datasource); bulkUpdateAdHocFiltersVariable(filterVar, items); }; diff --git a/public/app/features/dashboard-scene/utils/drilldownUtils.ts b/public/app/features/dashboard-scene/utils/drilldownUtils.ts index 2ff0ecf7c6e..cf6f1271162 100644 --- a/public/app/features/dashboard-scene/utils/drilldownUtils.ts +++ b/public/app/features/dashboard-scene/utils/drilldownUtils.ts @@ -3,6 +3,8 @@ import { getDataSourceSrv } from '@grafana/runtime'; import { AdHocFiltersVariable, GroupByVariable, sceneGraph, SceneObject, SceneQueryRunner } from '@grafana/scenes'; import { DataSourceRef } from '@grafana/schema'; +import { getDatasourceFromQueryRunner } from './utils'; + export function verifyDrilldownApplicability( sourceObject: SceneObject, queriesDataSource: DataSourceRef | undefined, @@ -26,7 +28,7 @@ export async function getDrilldownApplicability( return; } - const datasource = queryRunner.state.datasource; + const datasource = getDatasourceFromQueryRunner(queryRunner); const queries = queryRunner.state.data?.request?.targets; const ds = await getDataSourceSrv().get(datasource?.uid); diff --git a/public/app/features/dashboard-scene/utils/urlBuilders.ts b/public/app/features/dashboard-scene/utils/urlBuilders.ts index 942d378a38f..11f604d6990 100644 --- a/public/app/features/dashboard-scene/utils/urlBuilders.ts +++ b/public/app/features/dashboard-scene/utils/urlBuilders.ts @@ -4,7 +4,7 @@ import { sceneGraph, VizPanel } from '@grafana/scenes'; import { contextSrv } from 'app/core/services/context_srv'; import { getExploreUrl } from 'app/core/utils/explore'; -import { getQueryRunnerFor } from './utils'; +import { getDatasourceFromQueryRunner, getQueryRunnerFor } from './utils'; export function getViewPanelUrl(vizPanel: VizPanel) { return locationUtil.getUrlForPartial(locationService.getLocation(), { @@ -27,10 +27,11 @@ export function tryGetExploreUrlForPanel(vizPanel: VizPanel): Promise Date: Mon, 29 Dec 2025 10:10:04 -0500 Subject: [PATCH 093/229] E2E: Use updated setVisualization from grafana/e2e (#115640) --- .../panels-suite/canvas-scene.spec.ts | 6 +-- .../panels-suite/vizpicker-utils.ts | 24 --------- .../as-admin-user/panelDataAssertion.spec.ts | 9 ++-- .../as-admin-user/panelEditPage.spec.ts | 51 +++++++++---------- 4 files changed, 31 insertions(+), 59 deletions(-) delete mode 100644 e2e-playwright/panels-suite/vizpicker-utils.ts diff --git a/e2e-playwright/panels-suite/canvas-scene.spec.ts b/e2e-playwright/panels-suite/canvas-scene.spec.ts index b1fc028f3ae..c0b392d544b 100644 --- a/e2e-playwright/panels-suite/canvas-scene.spec.ts +++ b/e2e-playwright/panels-suite/canvas-scene.spec.ts @@ -2,18 +2,16 @@ import { Locator } from '@playwright/test'; import { test, expect } from '@grafana/plugin-e2e'; -import { setVisualization } from './vizpicker-utils'; - test.use({ featureToggles: { canvasPanelPanZoom: true, }, }); test.describe('Canvas Panel - Scene Tests', () => { - test.beforeEach(async ({ page, gotoDashboardPage, selectors }) => { + test.beforeEach(async ({ page, gotoDashboardPage }) => { const dashboardPage = await gotoDashboardPage({}); const panelEditPage = await dashboardPage.addPanel(); - await setVisualization(panelEditPage, 'Canvas', selectors); + await panelEditPage.setVisualization('Canvas'); // Wait for canvas panel to load await page.waitForSelector('[data-testid="canvas-scene-pan-zoom"]', { timeout: 10000 }); diff --git a/e2e-playwright/panels-suite/vizpicker-utils.ts b/e2e-playwright/panels-suite/vizpicker-utils.ts deleted file mode 100644 index 1785dd7e04a..00000000000 --- a/e2e-playwright/panels-suite/vizpicker-utils.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { expect, E2ESelectorGroups, PanelEditPage } from '@grafana/plugin-e2e'; - -// this replaces the panelEditPage.setVisualization method used previously in tests, since it -// does not know how to use the updated 12.4 viz picker UI to set the visualization -export const setVisualization = async (panelEditPage: PanelEditPage, vizName: string, selectors: E2ESelectorGroups) => { - const vizPicker = panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker); - await expect(vizPicker, '"Change" button should be visible').toBeVisible(); - await vizPicker.click(); - - const allVizTabBtn = panelEditPage.getByGrafanaSelector(selectors.components.Tab.title('All visualizations')); - await expect(allVizTabBtn, '"All visualiations" button should be visible').toBeVisible(); - await allVizTabBtn.click(); - - const vizItem = panelEditPage.getByGrafanaSelector(selectors.components.PluginVisualization.item(vizName)); - await expect(vizItem, `"${vizName}" item should be visible`).toBeVisible(); - await vizItem.scrollIntoViewIfNeeded(); - await vizItem.click(); - - await expect(vizPicker, '"Change" button should be visible again').toBeVisible(); - await expect( - panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.header), - 'Panel header should have the new viz type name' - ).toHaveText(vizName); -}; diff --git a/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelDataAssertion.spec.ts b/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelDataAssertion.spec.ts index 336dbef0a29..0133a3e3712 100644 --- a/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelDataAssertion.spec.ts +++ b/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelDataAssertion.spec.ts @@ -1,6 +1,5 @@ import { expect, test } from '@grafana/plugin-e2e'; -import { setVisualization } from '../../../panels-suite/vizpicker-utils'; import { formatExpectError } from '../errors'; import { successfulDataQuery } from '../mocks/queries'; @@ -25,10 +24,10 @@ test.describe( ).toContainText(['Field', 'Max', 'Mean', 'Last']); }); - test('table panel data assertions', async ({ panelEditPage, selectors }) => { + test('table panel data assertions', async ({ panelEditPage }) => { await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200); await panelEditPage.datasource.set('gdev-testdata'); - await setVisualization(panelEditPage, 'Table', selectors); + await panelEditPage.setVisualization('Table'); await panelEditPage.refreshPanel(); await expect( panelEditPage.panel.locator, @@ -44,10 +43,10 @@ test.describe( ).toContainText(['val1', 'val2', 'val3', 'val4']); }); - test('timeseries panel - table view assertions', async ({ panelEditPage, selectors }) => { + test('timeseries panel - table view assertions', async ({ panelEditPage }) => { await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200); await panelEditPage.datasource.set('gdev-testdata'); - await setVisualization(panelEditPage, 'Time series', selectors); + await panelEditPage.setVisualization('Time series'); await panelEditPage.refreshPanel(); await panelEditPage.toggleTableView(); await expect( diff --git a/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelEditPage.spec.ts b/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelEditPage.spec.ts index 93e0525ab0e..46c36277848 100644 --- a/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelEditPage.spec.ts +++ b/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelEditPage.spec.ts @@ -1,6 +1,5 @@ import { expect, test } from '@grafana/plugin-e2e'; -import { setVisualization } from '../../../panels-suite/vizpicker-utils'; import { formatExpectError } from '../errors'; import { successfulDataQuery } from '../mocks/queries'; import { scenarios } from '../mocks/resources'; @@ -54,10 +53,10 @@ test.describe( ).toHaveText(scenarios.map((s) => s.name)); }); - test('mocked query data response', async ({ panelEditPage, page, selectors }) => { + test('mocked query data response', async ({ panelEditPage, page }) => { await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200); await panelEditPage.datasource.set('gdev-testdata'); - await setVisualization(panelEditPage, TABLE_VIZ_NAME, selectors); + await panelEditPage.setVisualization(TABLE_VIZ_NAME); await panelEditPage.refreshPanel(); await expect( panelEditPage.panel.getErrorIcon(), @@ -76,7 +75,7 @@ test.describe( selectors, page, }) => { - await setVisualization(panelEditPage, TABLE_VIZ_NAME, selectors); + await panelEditPage.setVisualization(TABLE_VIZ_NAME); await expect( panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.header), formatExpectError('Expected panel visualization to be set to table') @@ -93,8 +92,8 @@ test.describe( ).toBeVisible(); }); - test('Select time zone in timezone picker', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('Select time zone in timezone picker', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const axisOptions = await panelEditPage.getCustomOptions('Axis'); const timeZonePicker = axisOptions.getSelect('Time zone'); @@ -102,8 +101,8 @@ test.describe( await expect(timeZonePicker).toHaveSelected('Europe/Stockholm'); }); - test('select unit in unit picker', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('select unit in unit picker', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const standardOptions = panelEditPage.getStandardOptions(); const unitPicker = standardOptions.getUnitPicker('Unit'); @@ -112,8 +111,8 @@ test.describe( await expect(unitPicker).toHaveSelected('Pixels'); }); - test('enter value in number input', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('enter value in number input', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const axisOptions = panelEditPage.getCustomOptions('Axis'); const lineWith = axisOptions.getNumberInput('Soft min'); @@ -122,8 +121,8 @@ test.describe( await expect(lineWith).toHaveValue('10'); }); - test('enter value in slider', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('enter value in slider', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const graphOptions = panelEditPage.getCustomOptions('Graph styles'); const lineWidth = graphOptions.getSliderInput('Line width'); @@ -132,8 +131,8 @@ test.describe( await expect(lineWidth).toHaveValue('10'); }); - test('select value in single value select', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('select value in single value select', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const standardOptions = panelEditPage.getStandardOptions(); const colorSchemeSelect = standardOptions.getSelect('Color scheme'); @@ -141,8 +140,8 @@ test.describe( await expect(colorSchemeSelect).toHaveSelected('Classic palette'); }); - test('clear input', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('clear input', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const panelOptions = panelEditPage.getPanelOptions(); const title = panelOptions.getTextInput('Title'); @@ -151,8 +150,8 @@ test.describe( await expect(title).toHaveValue(''); }); - test('enter value in input', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('enter value in input', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const panelOptions = panelEditPage.getPanelOptions(); const description = panelOptions.getTextInput('Description'); @@ -161,8 +160,8 @@ test.describe( await expect(description).toHaveValue('This is a panel'); }); - test('unchecking switch', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('unchecking switch', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const axisOptions = panelEditPage.getCustomOptions('Axis'); const showBorder = axisOptions.getSwitch('Show border'); @@ -174,8 +173,8 @@ test.describe( await expect(showBorder).toBeChecked({ checked: false }); }); - test('checking switch', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('checking switch', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const axisOptions = panelEditPage.getCustomOptions('Axis'); const showBorder = axisOptions.getSwitch('Show border'); @@ -184,8 +183,8 @@ test.describe( await expect(showBorder).toBeChecked(); }); - test('re-selecting value in radio button group', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('re-selecting value in radio button group', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const axisOptions = panelEditPage.getCustomOptions('Axis'); const placement = axisOptions.getRadioGroup('Placement'); @@ -196,8 +195,8 @@ test.describe( await expect(placement).toHaveChecked('Auto'); }); - test('selecting value in radio button group', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('selecting value in radio button group', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const axisOptions = panelEditPage.getCustomOptions('Axis'); const placement = axisOptions.getRadioGroup('Placement'); From 7182511bcf6ae8dd50f68557d2532486b45c522d Mon Sep 17 00:00:00 2001 From: Rodrigo Vasconcelos de Barros Date: Mon, 29 Dec 2025 10:18:42 -0500 Subject: [PATCH 094/229] Alerting: Auto-format numeric values in Alert Rule History (#115708) * Add helper function to format numeric values in alert rule history * Use formatting function in LogRecordViewer * Refactor numerical formatting logic * Handle edge cases when counting decimal places * Cleanup tests and numberFormatter code --- .../state-history/LogRecordViewer.test.tsx | 72 ++++++++ .../rules/state-history/LogRecordViewer.tsx | 3 +- .../state-history/numberFormatter.test.ts | 173 ++++++++++++++++++ .../rules/state-history/numberFormatter.ts | 75 ++++++++ 4 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 public/app/features/alerting/unified/components/rules/state-history/numberFormatter.test.ts create mode 100644 public/app/features/alerting/unified/components/rules/state-history/numberFormatter.ts diff --git a/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.test.tsx b/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.test.tsx index a90e9dc52a8..cbc5563538f 100644 --- a/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.test.tsx +++ b/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.test.tsx @@ -60,4 +60,76 @@ describe('LogRecordViewerByTimestamp', () => { expect(within(errorRows[1]).getByText(/Error message:/)).toBeInTheDocument(); expect(within(errorRows[1]).getByText(/explicit message/)).toBeInTheDocument(); }); + + describe('Numeric Value Formatting', () => { + it('should format numeric values correctly in AlertInstanceValues', () => { + const records: LogRecord[] = [ + { + timestamp: 1681739580000, + line: { + current: 'Alerting', + previous: 'Pending', + labels: {}, + values: { + cpu_usage: 42.987654321, + memory_mb: 1234567.89, + disk_io: 0.001234, + request_count: 10000, + }, + }, + }, + ]; + + render(); + + expect(screen.getByText(/cpu_usage/)).toBeInTheDocument(); + expect(screen.getByText(/4\.299e\+1/i)).toBeInTheDocument(); + + expect(screen.getByText(/memory_mb/)).toBeInTheDocument(); + expect(screen.getByText(/1\.235e\+6/i)).toBeInTheDocument(); + + expect(screen.getByText(/disk_io/)).toBeInTheDocument(); + expect(screen.getByText(/1\.234e-3/i)).toBeInTheDocument(); + + expect(screen.getByText(/request_count/)).toBeInTheDocument(); + expect(screen.getByText(/10000/)).toBeInTheDocument(); + }); + + it('should format various numeric ranges correctly', () => { + const records: LogRecord[] = [ + { + timestamp: 1681739580000, + line: { + current: 'Alerting', + previous: 'Pending', + labels: {}, + values: { + small: 0.001, + normal: 42.5, + large: 123456, + boundary_low: 0.01, + boundary_high: 10000, + }, + }, + }, + ]; + + render(); + + expect(screen.getByText(/small/)).toBeInTheDocument(); + expect(screen.getByText(/1\.000e-3/i)).toBeInTheDocument(); + + expect(screen.getByText(/normal/)).toBeInTheDocument(); + expect(screen.getByText(/42\.5/)).toBeInTheDocument(); + + expect(screen.getByText(/large/)).toBeInTheDocument(); + expect(screen.getByText(/1\.235e\+5/i)).toBeInTheDocument(); + + expect(screen.getByText(/boundary_low/)).toBeInTheDocument(); + expect(screen.getByText(/0\.01/)).toBeInTheDocument(); + + expect(screen.getByText(/boundary_high/)).toBeInTheDocument(); + expect(screen.getByText(/10000/)).toBeInTheDocument(); + }); + }); }); diff --git a/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.tsx b/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.tsx index c1d90347c74..06fcde4a1ae 100644 --- a/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.tsx +++ b/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.tsx @@ -13,6 +13,7 @@ import { AlertStateTag } from '../AlertStateTag'; import { ErrorMessageRow } from './ErrorMessageRow'; import { LogRecord, omitLabels } from './common'; +import { formatNumericValue } from './numberFormatter'; type LogRecordViewerProps = { records: LogRecord[]; @@ -182,7 +183,7 @@ const AlertInstanceValues = memo(({ record }: { record: Record } return ( <> {values.map(([key, value]) => ( - + ))} ); diff --git a/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.test.ts b/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.test.ts new file mode 100644 index 00000000000..77dfe40df5a --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.test.ts @@ -0,0 +1,173 @@ +import { formatNumericValue } from './numberFormatter'; + +describe('formatNumericValue', () => { + describe('Zero and special values', () => { + it('should format zero correctly', () => { + expect(formatNumericValue(0)).toBe('0'); + expect(formatNumericValue(-0)).toBe('0'); + }); + + it('should handle NaN', () => { + expect(formatNumericValue(NaN)).toBe('NaN'); + }); + + it('should handle Infinity', () => { + expect(formatNumericValue(Infinity)).toBe('Infinity'); + expect(formatNumericValue(-Infinity)).toBe('-Infinity'); + }); + }); + + describe('Very small numbers (scientific notation)', () => { + it('should use scientific notation for values less than 1e-2', () => { + const result1 = formatNumericValue(1e-3); + expect(result1).toMatch(/^1\.000e-3$/i); + + const result2 = formatNumericValue(0.001); + expect(result2).toMatch(/^1\.000e-3$/i); + + const result3 = formatNumericValue(0.009); + expect(result3).toMatch(/^9\.000e-3$/i); + }); + + it('should use scientific notation for values just below 1e-2', () => { + const result = formatNumericValue(0.00999); + expect(result).toMatch(/^9\.990e-3$/i); + }); + + it('should format the example from requirements correctly', () => { + // 1.4153928131348452 has > 4 decimal places, so should use scientific notation + const result = formatNumericValue(1.4153928131348452); + expect(result).toMatch(/^1\.415e\+0$/i); + }); + + it('should handle negative very small numbers', () => { + const result = formatNumericValue(-1e-3); + expect(result).toMatch(/^-1\.000e-3$/i); + + const result2 = formatNumericValue(-0.001); + expect(result2).toMatch(/^-1\.000e-3$/i); + }); + }); + + describe('Human-readable range (standard notation)', () => { + it('should use standard notation for boundary value 1e-2', () => { + expect(formatNumericValue(0.01)).toBe('0.01'); + }); + + it('should use standard notation for values in readable range', () => { + expect(formatNumericValue(0.1)).toBe('0.1'); + expect(formatNumericValue(1)).toBe('1'); + expect(formatNumericValue(1.234)).toBe('1.234'); + expect(formatNumericValue(42.5)).toBe('42.5'); + }); + + it('should limit to 4 decimal places without rounding integer parts', () => { + expect(formatNumericValue(123.456)).toBe('123.456'); + expect(formatNumericValue(1234.567)).toBe('1234.567'); + expect(formatNumericValue(9999.9)).toBe('9999.9'); + expect(formatNumericValue(9999.1234)).toBe('9999.1234'); + }); + + it('should use scientific notation for numbers with more than 4 decimal places', () => { + // Numbers with > 4 decimals should use scientific notation even in readable range + const result1 = formatNumericValue(123.456789); + expect(result1).toMatch(/^1\.235e\+2$/i); + + const result2 = formatNumericValue(1.23456789); + expect(result2).toMatch(/^1\.235e\+0$/i); + + const result3 = formatNumericValue(42.987654321); + expect(result3).toMatch(/^4\.299e\+1$/i); + }); + + it('should use standard notation for boundary value 1e4', () => { + expect(formatNumericValue(10000)).toBe('10000'); + }); + + it('should handle negative numbers in readable range', () => { + expect(formatNumericValue(-0.1)).toBe('-0.1'); + expect(formatNumericValue(-123.456)).toBe('-123.456'); + expect(formatNumericValue(-9999.9)).toBe('-9999.9'); + }); + + it('should use scientific notation for negative numbers with excessive precision', () => { + const result = formatNumericValue(-42.987654321); + expect(result).toMatch(/^-4\.299e\+1$/i); + }); + }); + + describe('Very large numbers (scientific notation)', () => { + it('should use scientific notation for values greater than 1e4', () => { + const result1 = formatNumericValue(10001); + expect(result1).toMatch(/^1\.000e\+4$/i); + + const result2 = formatNumericValue(123456); + expect(result2).toMatch(/^1\.235e\+5$/i); + }); + + it('should handle negative very large numbers', () => { + const result = formatNumericValue(-1e5); + expect(result).toMatch(/^-1\.000e\+5$/i); + + const result2 = formatNumericValue(-123456); + expect(result2).toMatch(/^-1\.235e\+5$/i); + }); + }); + + describe('Edge cases', () => { + it('should handle numbers exactly at boundaries', () => { + expect(formatNumericValue(0.01)).toBe('0.01'); + + const justBelow = formatNumericValue(0.009999); + expect(justBelow).toMatch(/^9\.999e-3$/i); + + expect(formatNumericValue(10000)).toBe('10000'); + + const justAbove = formatNumericValue(10001); + expect(justAbove).toMatch(/^1\.000e\+4$/i); + }); + + it('should use scientific notation for very precise decimals with > 4 decimal places', () => { + expect(formatNumericValue(1.23456789)).toMatch(/^1\.235e\+0$/i); + expect(formatNumericValue(123.456789)).toMatch(/^1\.235e\+2$/i); + expect(formatNumericValue(0.123456789)).toMatch(/^1\.235e-1$/i); + }); + + it('should use standard notation for numbers with exactly 4 or fewer decimal places', () => { + expect(formatNumericValue(1.2345)).toBe('1.2345'); + expect(formatNumericValue(0.1234)).toBe('0.1234'); + expect(formatNumericValue(123.4567)).toBe('123.4567'); + }); + }); + + describe('countDecimalPlaces edge cases', () => { + it('should handle numbers that toString() would convert to scientific notation', () => { + const result = formatNumericValue(1e-10); + expect(result).toMatch(/^1\.000e-10$/i); + + const result2 = formatNumericValue(1e10); + expect(result2).toMatch(/^1\.000e\+10$/i); + }); + + it('should correctly count decimals for numbers with trailing zeros', () => { + expect(formatNumericValue(1.234)).toBe('1.234'); + expect(formatNumericValue(1.2)).toBe('1.2'); + expect(formatNumericValue(1.0)).toBe('1'); + }); + + it('should handle boundary values correctly', () => { + expect(formatNumericValue(0.01)).toBe('0.01'); + expect(formatNumericValue(10000)).toBe('10000'); + + expect(formatNumericValue(0.01001)).toMatch(/^1\.001e-2$/i); + expect(formatNumericValue(9999.1234)).toBe('9999.1234'); + expect(formatNumericValue(9999.12345)).toMatch(/^9\.999e\+3$/i); + }); + + it('should handle numbers in readable range that have many decimals', () => { + expect(formatNumericValue(1.4153928131348452)).toMatch(/^1\.415e\+0$/i); + expect(formatNumericValue(42.987654321)).toMatch(/^4\.299e\+1$/i); + expect(formatNumericValue(123.456789)).toMatch(/^1\.235e\+2$/i); + }); + }); +}); diff --git a/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.ts b/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.ts new file mode 100644 index 00000000000..8e518c2c932 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.ts @@ -0,0 +1,75 @@ +const SCIENTIFIC_NOTATION_THRESHOLD_SMALL = 1e-2; +const SCIENTIFIC_NOTATION_THRESHOLD_LARGE = 1e4; +const MAX_DECIMAL_PLACES = 4; +const EXPONENTIAL_DECIMALS = 3; // 4 significant digits = 1 digit + 3 decimals + +const readableRangeFormatter = new Intl.NumberFormat(undefined, { + maximumFractionDigits: MAX_DECIMAL_PLACES, + useGrouping: false, +}); + +/** + * Counts the number of decimal places in a number. + * Only processes numbers in readable range (1e-2 to 1e4) to avoid + * toString() scientific notation issues for very large/small numbers. + * + * Uses toFixed(10) to ensure standard notation representation. + * 10 decimal places is sufficient to detect if a number has > 4 decimal places. + */ +function countDecimalPlaces(value: number): number { + if (Number.isInteger(value)) { + return 0; + } + + const absValue = Math.abs(value); + + // Only count decimals for numbers in readable range + if (absValue < SCIENTIFIC_NOTATION_THRESHOLD_SMALL || absValue > SCIENTIFIC_NOTATION_THRESHOLD_LARGE) { + return 0; + } + + const str = value.toFixed(10); + const decimalIndex = str.indexOf('.'); + + if (decimalIndex === -1) { + return 0; + } + + // Count decimal places, removing trailing zeros + const decimalPart = str.substring(decimalIndex + 1).replace(/0+$/, ''); + return decimalPart.length; +} + +/** + * Formats a numeric value for display in alert rule history. + * - For values in human-readable range (1e-2 to 1e4) with ≤ 4 decimal places: shows up to 4 decimal places + * - For very small values (< 1e-2): uses scientific notation with 4 significant digits + * - For very large values (> 1e4): uses scientific notation with 4 significant digits + * - For numbers with > 4 decimal places: uses scientific notation with 4 significant digits + * + * @param value - The number to format + * @returns A formatted string representation of the number + */ +export function formatNumericValue(value: number): string { + if (!Number.isFinite(value)) { + return String(value); + } + + if (value === 0) { + return '0'; + } + + const absValue = Math.abs(value); + + if (absValue < SCIENTIFIC_NOTATION_THRESHOLD_SMALL || absValue > SCIENTIFIC_NOTATION_THRESHOLD_LARGE) { + return value.toExponential(EXPONENTIAL_DECIMALS); + } + + const decimalPlaces = countDecimalPlaces(value); + + if (decimalPlaces > MAX_DECIMAL_PLACES) { + return value.toExponential(EXPONENTIAL_DECIMALS); + } + + return readableRangeFormatter.format(value); +} From e088c9aac9884f0820ad261fdb4c670f8829c7ed Mon Sep 17 00:00:00 2001 From: Matheus Macabu Date: Mon, 29 Dec 2025 16:28:29 +0100 Subject: [PATCH 095/229] Auditing: Add feature flag (#115726) --- .../grafana-data/src/types/featureToggles.gen.ts | 4 ++++ pkg/services/featuremgmt/registry.go | 8 ++++++++ pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 ++++ pkg/services/featuremgmt/toggles_gen.json | 14 ++++++++++++++ 5 files changed, 31 insertions(+) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 981b10dfb1c..04b0b28847c 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -421,6 +421,10 @@ export interface FeatureToggles { */ jitterAlertRulesWithinGroups?: boolean; /** + * Enable audit logging with Kubernetes under app platform + */ + auditLoggingAppPlatform?: boolean; + /** * Enable the secrets management API and services under app platform */ secretsManagementAppPlatform?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index d6f2bcbec2e..22e832034bc 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -688,6 +688,14 @@ var ( HideFromDocs: true, RequiresRestart: true, }, + { + Name: "auditLoggingAppPlatform", + Description: "Enable audit logging with Kubernetes under app platform", + Stage: FeatureStageExperimental, + Owner: grafanaOperatorExperienceSquad, + HideFromDocs: true, + RequiresRestart: true, + }, { Name: "secretsManagementAppPlatform", Description: "Enable the secrets management API and services under app platform", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 179568aa0c4..87001f263f8 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -95,6 +95,7 @@ kubernetesFeatureToggles,experimental,@grafana/grafana-operator-experience-squad cloudRBACRoles,preview,@grafana/identity-access-team,false,true,false alertingQueryOptimization,GA,@grafana/alerting-squad,false,false,false jitterAlertRulesWithinGroups,preview,@grafana/alerting-squad,false,true,false +auditLoggingAppPlatform,experimental,@grafana/grafana-operator-experience-squad,false,true,false secretsManagementAppPlatform,experimental,@grafana/grafana-operator-experience-squad,false,false,false secretsManagementAppPlatformUI,experimental,@grafana/grafana-operator-experience-squad,false,false,false alertingSaveStatePeriodic,privatePreview,@grafana/alerting-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 2797b046d57..6543d31dba5 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -279,6 +279,10 @@ const ( // Distributes alert rule evaluations more evenly over time, including spreading out rules within the same group. Disables sequential evaluation if enabled. FlagJitterAlertRulesWithinGroups = "jitterAlertRulesWithinGroups" + // FlagAuditLoggingAppPlatform + // Enable audit logging with Kubernetes under app platform + FlagAuditLoggingAppPlatform = "auditLoggingAppPlatform" + // FlagSecretsManagementAppPlatform // Enable the secrets management API and services under app platform FlagSecretsManagementAppPlatform = "secretsManagementAppPlatform" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 42922ecf82d..5bea1b2e40f 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -658,6 +658,20 @@ "frontend": true } }, + { + "metadata": { + "name": "auditLoggingAppPlatform", + "resourceVersion": "1767013056996", + "creationTimestamp": "2025-12-29T12:57:36Z" + }, + "spec": { + "description": "Enable audit logging with Kubernetes under app platform", + "stage": "experimental", + "codeowner": "@grafana/grafana-operator-experience-squad", + "requiresRestart": true, + "hideFromDocs": true + } + }, { "metadata": { "name": "authZGRPCServer", From 4c79775b574ffe848cead70186ef1cf8dd1f5079 Mon Sep 17 00:00:00 2001 From: linoman <2051016+linoman@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:19:49 +0100 Subject: [PATCH 096/229] auth: Protect from empty session token panic (#115728) * Protect from empty session token panic * Rename returned error --- pkg/services/auth/auth.go | 7 ++++--- pkg/services/oauthtoken/oauth_token.go | 4 ++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/pkg/services/auth/auth.go b/pkg/services/auth/auth.go index cc678914b31..76b60b68517 100644 --- a/pkg/services/auth/auth.go +++ b/pkg/services/auth/auth.go @@ -20,9 +20,10 @@ const ( // Typed errors var ( - ErrUserTokenNotFound = errors.New("user token not found") - ErrInvalidSessionToken = usertoken.ErrInvalidSessionToken - ErrExternalSessionNotFound = errors.New("external session not found") + ErrUserTokenNotFound = errors.New("user token not found") + ErrInvalidSessionToken = usertoken.ErrInvalidSessionToken + ErrExternalSessionNotFound = errors.New("external session not found") + ErrExternalSessionTokenNotFound = errors.New("session token was nil") ) type ( diff --git a/pkg/services/oauthtoken/oauth_token.go b/pkg/services/oauthtoken/oauth_token.go index 0efe5e553f3..6d320251ccc 100644 --- a/pkg/services/oauthtoken/oauth_token.go +++ b/pkg/services/oauthtoken/oauth_token.go @@ -660,6 +660,10 @@ func (o *Service) getExternalSession(ctx context.Context, usr identity.Requester return externalSessions[0], nil } + if sessionToken == nil { + return nil, auth.ErrExternalSessionTokenNotFound + } + // For regular users, we use the session token ID to fetch the external session return o.sessionService.GetExternalSession(ctx, sessionToken.ExternalSessionId) } From 0c6b97bee2b91dadbe44d4900ab9862545626039 Mon Sep 17 00:00:00 2001 From: ismail simsek Date: Mon, 29 Dec 2025 19:11:44 +0100 Subject: [PATCH 097/229] Prometheus: Fallback to fetch metric names when metadata returns nothing (#115369) fallback to fetch metric names when metadata returns nothing --- .../metrics-modal/MetricsModal.test.tsx | 8 +++++-- .../components/metrics-modal/MetricsModal.tsx | 2 +- .../MetricsModalContext.test.tsx | 24 +++++++++++++++---- .../metrics-modal/MetricsModalContext.tsx | 16 ++++++++++--- 4 files changed, 40 insertions(+), 10 deletions(-) diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.test.tsx index f57fbb59bd6..a91179fad3e 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.test.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.test.tsx @@ -48,7 +48,7 @@ describe('MetricsModal', () => { operations: [], }; - setup(query, ['with-labels'], true); + setup(query, ['with-labels']); await waitFor(() => { expect(screen.getByText('with-labels')).toBeInTheDocument(); }); @@ -220,6 +220,10 @@ function createDatasource(withLabels?: boolean) { // display different results if their labels are selected in the PromVisualQuery if (withLabels) { languageProvider.queryMetricsMetadata = jest.fn().mockResolvedValue({ + ALERTS: { + type: 'gauge', + help: 'alerts help text', + }, 'with-labels': { type: 'with-labels-type', help: 'with-labels-help', @@ -297,7 +301,7 @@ function createProps(query: PromVisualQuery, datasource: PrometheusDatasource, m }; } -function setup(query: PromVisualQuery, metrics: string[], withlabels?: boolean) { +function setup(query: PromVisualQuery, metrics: string[]) { const withLabels: boolean = query.labels.length > 0; const datasource = createDatasource(withLabels); const props = createProps(query, datasource, metrics); diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.tsx b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.tsx index 59c4c703ccf..bf92a3ddc77 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.tsx @@ -138,7 +138,7 @@ const MetricsModalContent = (props: MetricsModalProps) => { export const MetricsModal = (props: MetricsModalProps) => { return ( - + ); diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.test.tsx index 955b2c1b585..46082f476b5 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.test.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.test.tsx @@ -4,6 +4,7 @@ import { ReactNode } from 'react'; import { TimeRange } from '@grafana/data'; import { PrometheusLanguageProviderInterface } from '../../../language_provider'; +import { getMockTimeRange } from '../../../test/mocks/datasource'; import { DEFAULT_RESULTS_PER_PAGE, MetricsModalContextProvider, useMetricsModal } from './MetricsModalContext'; import { generateMetricData } from './helpers'; @@ -25,7 +26,9 @@ const mockLanguageProvider: PrometheusLanguageProviderInterface = { // Helper to create wrapper component const createWrapper = (languageProvider = mockLanguageProvider) => { return ({ children }: { children: ReactNode }) => ( - {children} + + {children} + ); }; @@ -167,6 +170,7 @@ describe('MetricsModalContext', () => { it('should handle empty metadata response', async () => { (mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({}); + (mockLanguageProvider.queryLabelValues as jest.Mock).mockResolvedValue(['metric1', 'metric2']); const { result } = renderHook(() => useMetricsModal(), { wrapper: createWrapper(), @@ -176,7 +180,18 @@ describe('MetricsModalContext', () => { expect(result.current.isLoading).toBe(false); }); - expect(result.current.filteredMetricsData).toEqual([]); + expect(result.current.filteredMetricsData).toEqual([ + { + value: 'metric1', + type: 'counter', + description: 'Test metric', + }, + { + value: 'metric2', + type: 'counter', + description: 'Test metric', + }, + ]); }); it('should handle metadata fetch error', async () => { @@ -239,6 +254,7 @@ describe('MetricsModalContext', () => { })); (mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({ + ALERTS: { type: 'gauge', help: 'Test alerts help' }, test_metric: { type: 'counter', help: 'Test metric' }, }); @@ -250,7 +266,7 @@ describe('MetricsModalContext', () => { expect(result.current.isLoading).toBe(false); }); - expect(result.current.filteredMetricsData).toHaveLength(1); + expect(result.current.filteredMetricsData).toHaveLength(2); expect(result.current.selectedTypes).toEqual([]); }); @@ -318,7 +334,7 @@ describe('MetricsModalContext', () => { }; const { getByTestId } = render( - + ); diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.tsx b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.tsx index 3361b448547..117e3aad56e 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.tsx @@ -52,11 +52,13 @@ const MetricsModalContext = createContext( type MetricsModalContextProviderProps = { languageProvider: PrometheusLanguageProviderInterface; + timeRange: TimeRange; }; export const MetricsModalContextProvider: FC> = ({ children, languageProvider, + timeRange, }) => { const [isLoading, setIsLoading] = useState(true); const [metricsData, setMetricsData] = useState([]); @@ -111,8 +113,16 @@ export const MetricsModalContextProvider: FC generateMetricData(m, languageProvider)); + setMetricsData(processedData); } else { const processedData = Object.keys(metadata).map((m) => generateMetricData(m, languageProvider)); setMetricsData(processedData); @@ -122,7 +132,7 @@ export const MetricsModalContextProvider: FC From 5c0ee2d7461c02d5d345521a6344a84a1958a2a1 Mon Sep 17 00:00:00 2001 From: Lewis John McGibbney Date: Mon, 29 Dec 2025 23:46:57 -0800 Subject: [PATCH 098/229] Documentation: Fix JSON file export relative link (#115650) --- .../visualizations/dashboards/share-dashboards-panels/_index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sources/visualizations/dashboards/share-dashboards-panels/_index.md b/docs/sources/visualizations/dashboards/share-dashboards-panels/_index.md index 5fcd2344fe2..e7749ba5b88 100644 --- a/docs/sources/visualizations/dashboards/share-dashboards-panels/_index.md +++ b/docs/sources/visualizations/dashboards/share-dashboards-panels/_index.md @@ -98,7 +98,7 @@ You can share dashboards in the following ways: - [As a report](#schedule-a-report) - [As a snapshot](#share-a-snapshot) - [As a PDF export](#export-a-dashboard-as-pdf) -- [As a JSON file export](#export-a-dashboard-as-json) +- [As a JSON file export](#export-a-dashboard-as-code) - [As an image export](#export-a-dashboard-as-an-image) When you share a dashboard externally as a link or by email, those dashboards are included in a list of your shared dashboards. To view the list and manage these dashboards, navigate to **Dashboards > Shared dashboards**. From 6e155523a3c41133aadccd787981e7e3898d9669 Mon Sep 17 00:00:00 2001 From: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Tue, 30 Dec 2025 03:14:06 -0500 Subject: [PATCH 099/229] Plugins App: Add basic README (#115507) * Plugins App: Add basic README * prettier:write --------- Co-authored-by: Ryan McKinley --- apps/plugins/README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 apps/plugins/README.md diff --git a/apps/plugins/README.md b/apps/plugins/README.md new file mode 100644 index 00000000000..7f91dd6ea12 --- /dev/null +++ b/apps/plugins/README.md @@ -0,0 +1,20 @@ +# Plugins App + +API documentation is available at http://localhost:3000/swagger?api=plugins.grafana.app-v0alpha1 + +## Codegen + +- Go: `make generate` +- Frontend: Follow instructions in this [README](../..//packages/grafana-api-clients/README.md) + +## Plugin sync + +The plugin sync pushes the plugins loaded from disk to the plugins API. + +To enable, add these feature toggles in your `custom.ini`: + +```ini +[feature_toggles] +pluginInstallAPISync = true +pluginStoreServiceLoading = true +``` From 759035a465acada67d1ffed9620371b0a32f1f4a Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Tue, 30 Dec 2025 09:45:33 +0100 Subject: [PATCH 100/229] Remove kubernetesDashboardsV2 feature toggle (#114912) Co-authored-by: Haris Rozajac --- .../dashboards-suite/dashboard-browse-nested.spec.ts | 2 +- e2e-playwright/dashboards-suite/dashboard-browse.spec.ts | 2 +- .../dashboards-suite/dashboard-export-image.spec.ts | 2 +- .../dashboards-suite/dashboard-export-json.spec.ts | 2 +- .../dashboards-suite/dashboard-keybindings.spec.ts | 2 +- .../dashboards-suite/dashboard-links-without-slug.spec.ts | 2 +- .../dashboards-suite/dashboard-live-streaming.spec.ts | 2 +- .../dashboards-suite/dashboard-public-create.spec.ts | 2 +- .../dashboards-suite/dashboard-public-templating.spec.ts | 2 +- .../dashboard-share-externally-create.spec.ts | 2 +- .../dashboards-suite/dashboard-share-internally.spec.ts | 2 +- .../dashboard-share-snapshot-create.spec.ts | 2 +- .../dashboards-suite/dashboard-templating.spec.ts | 2 +- .../dashboards-suite/dashboard-time-zone.spec.ts | 2 +- .../dashboards-suite/dashboard-timepicker.spec.ts | 2 +- e2e-playwright/dashboards-suite/embedded-dashboard.spec.ts | 2 +- e2e-playwright/dashboards-suite/general-dashboards.spec.ts | 2 +- e2e-playwright/dashboards-suite/import-dashboard.spec.ts | 2 +- .../dashboards-suite/load-options-from-url.spec.ts | 2 +- .../dashboards-suite/new-constant-variable.spec.ts | 2 +- .../dashboards-suite/new-custom-variable.spec.ts | 2 +- .../dashboards-suite/new-datasource-variable.spec.ts | 2 +- .../dashboards-suite/new-interval-variable.spec.ts | 2 +- e2e-playwright/dashboards-suite/new-query-variable.spec.ts | 2 +- .../dashboards-suite/new-text-box-variable.spec.ts | 2 +- .../repeating-a-panel-horizontally.spec.ts | 2 +- .../dashboards-suite/repeating-a-panel-vertically.spec.ts | 2 +- .../dashboards-suite/repeating-an-empty-row.spec.ts | 2 +- .../dashboards-suite/set-options-from-ui.spec.ts | 2 +- e2e-playwright/dashboards-suite/snapshot-create.spec.ts | 2 +- .../templating-dashboard-links-and-variables.spec.ts | 2 +- e2e-playwright/dashboards-suite/textbox-variables.spec.ts | 2 +- go.mod | 2 +- packages/grafana-data/src/types/featureToggles.gen.ts | 4 ---- pkg/extensions/enterprise_imports.go | 6 +++--- pkg/registry/apis/dashboard/register.go | 2 +- pkg/services/featuremgmt/registry.go | 7 ------- pkg/services/featuremgmt/toggles_gen.csv | 1 - pkg/services/featuremgmt/toggles_gen.go | 4 ---- pkg/services/featuremgmt/toggles_gen.json | 5 +++-- .../pages/DashboardScenePageStateManager.ts | 2 +- public/app/features/dashboard/api/utils.ts | 3 +-- 42 files changed, 42 insertions(+), 58 deletions(-) diff --git a/e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts b/e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts index 6765d299e53..caa83bfdb90 100644 --- a/e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts @@ -10,7 +10,7 @@ const NUM_NESTED_DASHBOARDS = 60; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-browse.spec.ts b/e2e-playwright/dashboards-suite/dashboard-browse.spec.ts index 8ae318bcefa..6949eca4555 100644 --- a/e2e-playwright/dashboards-suite/dashboard-browse.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-browse.spec.ts @@ -5,7 +5,7 @@ import testDashboard from '../dashboards/TestDashboard.json'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-export-image.spec.ts b/e2e-playwright/dashboards-suite/dashboard-export-image.spec.ts index a97a04a14b8..e15991514a2 100644 --- a/e2e-playwright/dashboards-suite/dashboard-export-image.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-export-image.spec.ts @@ -7,7 +7,7 @@ test.use({ scenes: true, sharingDashboardImage: true, // Enable the export image feature kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-export-json.spec.ts b/e2e-playwright/dashboards-suite/dashboard-export-json.spec.ts index 428193ab5fa..26a8fb61dc8 100644 --- a/e2e-playwright/dashboards-suite/dashboard-export-json.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-export-json.spec.ts @@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts b/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts index b0ecf44f9f1..f874cefa27c 100644 --- a/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts @@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-links-without-slug.spec.ts b/e2e-playwright/dashboards-suite/dashboard-links-without-slug.spec.ts index 0a982e148b5..ca05fd24160 100644 --- a/e2e-playwright/dashboards-suite/dashboard-links-without-slug.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-links-without-slug.spec.ts @@ -5,7 +5,7 @@ import testDashboard from '../dashboards/DataLinkWithoutSlugTest.json'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-live-streaming.spec.ts b/e2e-playwright/dashboards-suite/dashboard-live-streaming.spec.ts index 20f455ea3a8..b7e18e56b45 100644 --- a/e2e-playwright/dashboards-suite/dashboard-live-streaming.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-live-streaming.spec.ts @@ -5,7 +5,7 @@ import testDashboard from '../dashboards/DashboardLiveTest.json'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-public-create.spec.ts b/e2e-playwright/dashboards-suite/dashboard-public-create.spec.ts index fd5dc979d81..9653218f5ff 100644 --- a/e2e-playwright/dashboards-suite/dashboard-public-create.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-public-create.spec.ts @@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-public-templating.spec.ts b/e2e-playwright/dashboards-suite/dashboard-public-templating.spec.ts index c59e323076d..9f15a740fcf 100644 --- a/e2e-playwright/dashboards-suite/dashboard-public-templating.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-public-templating.spec.ts @@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-share-externally-create.spec.ts b/e2e-playwright/dashboards-suite/dashboard-share-externally-create.spec.ts index 3398e9aaa35..3827872c199 100644 --- a/e2e-playwright/dashboards-suite/dashboard-share-externally-create.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-share-externally-create.spec.ts @@ -4,7 +4,7 @@ test.use({ featureToggles: { scenes: true, kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-share-internally.spec.ts b/e2e-playwright/dashboards-suite/dashboard-share-internally.spec.ts index 26f8b85d13e..6ff1825f9cd 100644 --- a/e2e-playwright/dashboards-suite/dashboard-share-internally.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-share-internally.spec.ts @@ -4,7 +4,7 @@ test.use({ featureToggles: { scenes: true, kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-share-snapshot-create.spec.ts b/e2e-playwright/dashboards-suite/dashboard-share-snapshot-create.spec.ts index 1a7e03d6243..b5c77458121 100644 --- a/e2e-playwright/dashboards-suite/dashboard-share-snapshot-create.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-share-snapshot-create.spec.ts @@ -6,7 +6,7 @@ test.use({ featureToggles: { scenes: true, kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-templating.spec.ts b/e2e-playwright/dashboards-suite/dashboard-templating.spec.ts index 78c35dc5de7..0a4eb5d3a9a 100644 --- a/e2e-playwright/dashboards-suite/dashboard-templating.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-templating.spec.ts @@ -6,7 +6,7 @@ test.use({ timezoneId: 'Pacific/Easter', featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts b/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts index 937224290b0..ee3de574512 100644 --- a/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts @@ -8,7 +8,7 @@ const TIMEZONE_DASHBOARD_UID = 'd41dbaa2-a39e-4536-ab2b-caca52f1a9c8'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-timepicker.spec.ts b/e2e-playwright/dashboards-suite/dashboard-timepicker.spec.ts index 4ffd65b83a3..ae2f08b230f 100644 --- a/e2e-playwright/dashboards-suite/dashboard-timepicker.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-timepicker.spec.ts @@ -17,7 +17,7 @@ test.use({ }, featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/embedded-dashboard.spec.ts b/e2e-playwright/dashboards-suite/embedded-dashboard.spec.ts index f457eddf19e..aad8ed3367a 100644 --- a/e2e-playwright/dashboards-suite/embedded-dashboard.spec.ts +++ b/e2e-playwright/dashboards-suite/embedded-dashboard.spec.ts @@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/general-dashboards.spec.ts b/e2e-playwright/dashboards-suite/general-dashboards.spec.ts index 99f84cb6d9f..d3ba9d9046e 100644 --- a/e2e-playwright/dashboards-suite/general-dashboards.spec.ts +++ b/e2e-playwright/dashboards-suite/general-dashboards.spec.ts @@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = 'edediimbjhdz4b/a-tall-dashboard'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/import-dashboard.spec.ts b/e2e-playwright/dashboards-suite/import-dashboard.spec.ts index 5fdca6954aa..f489f576887 100644 --- a/e2e-playwright/dashboards-suite/import-dashboard.spec.ts +++ b/e2e-playwright/dashboards-suite/import-dashboard.spec.ts @@ -5,7 +5,7 @@ import testDashboard from '../dashboards/TestDashboard.json'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/load-options-from-url.spec.ts b/e2e-playwright/dashboards-suite/load-options-from-url.spec.ts index ca06e31528a..08847be8d8e 100644 --- a/e2e-playwright/dashboards-suite/load-options-from-url.spec.ts +++ b/e2e-playwright/dashboards-suite/load-options-from-url.spec.ts @@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-constant-variable.spec.ts b/e2e-playwright/dashboards-suite/new-constant-variable.spec.ts index fa0b5fc1bfd..0abd9d248f1 100644 --- a/e2e-playwright/dashboards-suite/new-constant-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-constant-variable.spec.ts @@ -6,7 +6,7 @@ const DASHBOARD_NAME = 'Test variable output'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-custom-variable.spec.ts b/e2e-playwright/dashboards-suite/new-custom-variable.spec.ts index c14a952e1d9..79e545c9a41 100644 --- a/e2e-playwright/dashboards-suite/new-custom-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-custom-variable.spec.ts @@ -53,7 +53,7 @@ async function assertPreviewValues( test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-datasource-variable.spec.ts b/e2e-playwright/dashboards-suite/new-datasource-variable.spec.ts index cc19e67cda4..03859ccc5ea 100644 --- a/e2e-playwright/dashboards-suite/new-datasource-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-datasource-variable.spec.ts @@ -6,7 +6,7 @@ const DASHBOARD_NAME = 'Test variable output'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-interval-variable.spec.ts b/e2e-playwright/dashboards-suite/new-interval-variable.spec.ts index d76b5291c42..9c5cc8cc60f 100644 --- a/e2e-playwright/dashboards-suite/new-interval-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-interval-variable.spec.ts @@ -19,7 +19,7 @@ async function assertPreviewValues( test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-query-variable.spec.ts b/e2e-playwright/dashboards-suite/new-query-variable.spec.ts index 852261dbaf5..96375f3fb97 100644 --- a/e2e-playwright/dashboards-suite/new-query-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-query-variable.spec.ts @@ -6,7 +6,7 @@ const DASHBOARD_NAME = 'Templating - Nested Template Variables'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-text-box-variable.spec.ts b/e2e-playwright/dashboards-suite/new-text-box-variable.spec.ts index c669dc563c4..ba3b9466e7d 100644 --- a/e2e-playwright/dashboards-suite/new-text-box-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-text-box-variable.spec.ts @@ -6,7 +6,7 @@ const DASHBOARD_NAME = 'Test variable output'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/repeating-a-panel-horizontally.spec.ts b/e2e-playwright/dashboards-suite/repeating-a-panel-horizontally.spec.ts index a55f14b8643..413466972e1 100644 --- a/e2e-playwright/dashboards-suite/repeating-a-panel-horizontally.spec.ts +++ b/e2e-playwright/dashboards-suite/repeating-a-panel-horizontally.spec.ts @@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = 'WVpf2jp7z/repeating-a-panel-horizontally'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/repeating-a-panel-vertically.spec.ts b/e2e-playwright/dashboards-suite/repeating-a-panel-vertically.spec.ts index bb188c87e8d..53966eadf05 100644 --- a/e2e-playwright/dashboards-suite/repeating-a-panel-vertically.spec.ts +++ b/e2e-playwright/dashboards-suite/repeating-a-panel-vertically.spec.ts @@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = 'OY8Ghjt7k/repeating-a-panel-vertically'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/repeating-an-empty-row.spec.ts b/e2e-playwright/dashboards-suite/repeating-an-empty-row.spec.ts index e31c5792062..06c0e77989b 100644 --- a/e2e-playwright/dashboards-suite/repeating-an-empty-row.spec.ts +++ b/e2e-playwright/dashboards-suite/repeating-an-empty-row.spec.ts @@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = 'dtpl2Ctnk/repeating-an-empty-row'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/set-options-from-ui.spec.ts b/e2e-playwright/dashboards-suite/set-options-from-ui.spec.ts index 53290345e73..629abda2ce3 100644 --- a/e2e-playwright/dashboards-suite/set-options-from-ui.spec.ts +++ b/e2e-playwright/dashboards-suite/set-options-from-ui.spec.ts @@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/snapshot-create.spec.ts b/e2e-playwright/dashboards-suite/snapshot-create.spec.ts index 123aa7f3279..50febc211b8 100644 --- a/e2e-playwright/dashboards-suite/snapshot-create.spec.ts +++ b/e2e-playwright/dashboards-suite/snapshot-create.spec.ts @@ -5,7 +5,7 @@ const DASHBOARD_UID = 'ZqZnVvFZz'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off }, }); diff --git a/e2e-playwright/dashboards-suite/templating-dashboard-links-and-variables.spec.ts b/e2e-playwright/dashboards-suite/templating-dashboard-links-and-variables.spec.ts index 1d8fd32ff06..1806aca24bf 100644 --- a/e2e-playwright/dashboards-suite/templating-dashboard-links-and-variables.spec.ts +++ b/e2e-playwright/dashboards-suite/templating-dashboard-links-and-variables.spec.ts @@ -5,7 +5,7 @@ const DASHBOARD_UID = 'yBCC3aKGk'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/textbox-variables.spec.ts b/e2e-playwright/dashboards-suite/textbox-variables.spec.ts index 4fb56ef8b8e..b78e781dad1 100644 --- a/e2e-playwright/dashboards-suite/textbox-variables.spec.ts +++ b/e2e-playwright/dashboards-suite/textbox-variables.spec.ts @@ -7,7 +7,7 @@ const PAGE_UNDER_TEST = 'AejrN1AMz'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/go.mod b/go.mod index f22d410c51f..b848514cea4 100644 --- a/go.mod +++ b/go.mod @@ -52,7 +52,7 @@ require ( github.com/crewjam/saml v0.4.14 // @grafana/identity-access-team github.com/dgraph-io/badger/v4 v4.7.0 // @grafana/grafana-search-and-storage github.com/dlmiddlecote/sqlstats v1.0.2 // @grafana/grafana-backend-group - github.com/docker/go-connections v0.6.0 // @grafana/grafana-app-platform-squad + github.com/docker/go-connections v0.6.0 // indirect; @grafana/grafana-app-platform-squad github.com/dolthub/go-mysql-server v0.19.1-0.20250410182021-5632d67cd46e // @grafana/grafana-datasources-core-services github.com/dolthub/vitess v0.0.0-20250930230441-70c2c6a98e33 // @grafana/grafana-datasources-core-services github.com/dustin/go-humanize v1.0.1 // @grafana/observability-traces-and-profiling diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 04b0b28847c..aebbab8c6f9 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -356,10 +356,6 @@ export interface FeatureToggles { */ dashboardNewLayouts?: boolean; /** - * Use the v2 kubernetes API in the frontend for dashboards - */ - kubernetesDashboardsV2?: boolean; - /** * Enables undo/redo in dynamic dashboards */ dashboardUndoRedo?: boolean; diff --git a/pkg/extensions/enterprise_imports.go b/pkg/extensions/enterprise_imports.go index 472652cc103..113c2f8e4bb 100644 --- a/pkg/extensions/enterprise_imports.go +++ b/pkg/extensions/enterprise_imports.go @@ -15,7 +15,6 @@ import ( _ "github.com/blugelabs/bluge" _ "github.com/blugelabs/bluge_segment_api" _ "github.com/crewjam/saml" - _ "github.com/docker/go-connections/nat" _ "github.com/go-jose/go-jose/v4" _ "github.com/gobwas/glob" _ "github.com/googleapis/gax-go/v2" @@ -31,7 +30,6 @@ import ( _ "github.com/spf13/cobra" // used by the standalone apiserver cli _ "github.com/spyzhov/ajson" _ "github.com/stretchr/testify/require" - _ "github.com/testcontainers/testcontainers-go" _ "gocloud.dev/secrets/awskms" _ "gocloud.dev/secrets/azurekeyvault" _ "gocloud.dev/secrets/gcpkms" @@ -56,7 +54,9 @@ import ( _ "github.com/grafana/e2e" _ "github.com/grafana/gofpdf" _ "github.com/grafana/gomemcache/memcache" + _ "github.com/grafana/tempo/pkg/traceql" + _ "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1" _ "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1" - _ "github.com/grafana/tempo/pkg/traceql" + _ "github.com/testcontainers/testcontainers-go" ) diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index e651a5716ac..eeeb76f924e 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -237,7 +237,7 @@ func NewAPIService(ac authlib.AccessClient, features featuremgmt.FeatureToggles, } func (b *DashboardsAPIBuilder) GetGroupVersions() []schema.GroupVersion { - if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts, featuremgmt.FlagKubernetesDashboardsV2) { + if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts) { // If dashboards v2 is enabled, we want to use v2beta1 as the default API version. return []schema.GroupVersion{ dashv2beta1.DashboardResourceInfo.GroupVersion(), diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 22e832034bc..3748db8e6b4 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -572,13 +572,6 @@ var ( FrontendOnly: false, // The restore backend feature changes behavior based on this flag Owner: grafanaDashboardsSquad, }, - { - Name: "kubernetesDashboardsV2", - Description: "Use the v2 kubernetes API in the frontend for dashboards", - Stage: FeatureStageExperimental, - FrontendOnly: false, - Owner: grafanaDashboardsSquad, - }, { Name: "dashboardUndoRedo", Description: "Enables undo/redo in dynamic dashboards", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 87001f263f8..0c85021cff8 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -79,7 +79,6 @@ dashboardSceneForViewers,GA,@grafana/dashboards-squad,false,false,true dashboardSceneSolo,GA,@grafana/dashboards-squad,false,false,true dashboardScene,GA,@grafana/dashboards-squad,false,false,true dashboardNewLayouts,experimental,@grafana/dashboards-squad,false,false,false -kubernetesDashboardsV2,experimental,@grafana/dashboards-squad,false,false,false dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true drilldownRecommendations,experimental,@grafana/dashboards-squad,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 6543d31dba5..5de71954e2f 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -259,10 +259,6 @@ const ( // Enables experimental new dashboard layouts FlagDashboardNewLayouts = "dashboardNewLayouts" - // FlagKubernetesDashboardsV2 - // Use the v2 kubernetes API in the frontend for dashboards - FlagKubernetesDashboardsV2 = "kubernetesDashboardsV2" - // FlagPdfTables // Enables generating table data as PDF in reporting FlagPdfTables = "pdfTables" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 5bea1b2e40f..6d55a6ca617 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2017,8 +2017,9 @@ { "metadata": { "name": "kubernetesDashboardsV2", - "resourceVersion": "1764664939750", - "creationTimestamp": "2025-12-02T08:42:19Z" + "resourceVersion": "1764236054307", + "creationTimestamp": "2025-11-27T09:34:14Z", + "deletionTimestamp": "2025-12-05T13:43:57Z" }, "spec": { "description": "Use the v2 kubernetes API in the frontend for dashboards", diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 3cc0df33e8a..097c0d8d26c 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -959,7 +959,7 @@ export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateMan } export function shouldForceV2API(): boolean { - return Boolean(config.featureToggles.kubernetesDashboardsV2 || config.featureToggles.dashboardNewLayouts); + return Boolean(config.featureToggles.dashboardNewLayouts); } export class UnifiedDashboardScenePageStateManager extends DashboardScenePageStateManagerBase< diff --git a/public/app/features/dashboard/api/utils.ts b/public/app/features/dashboard/api/utils.ts index ab3e995fc34..af4cb6ecd04 100644 --- a/public/app/features/dashboard/api/utils.ts +++ b/public/app/features/dashboard/api/utils.ts @@ -20,7 +20,6 @@ export function isV0V1StoredVersion(version: string | undefined): boolean { export function getDashboardsApiVersion(responseFormat?: 'v1' | 'v2') { const isDashboardSceneEnabled = config.featureToggles.dashboardScene; const isKubernetesDashboardsEnabled = config.featureToggles.kubernetesDashboards; - const isV2DashboardAPIVersionEnabled = config.featureToggles.kubernetesDashboardsV2; const isDashboardNewLayoutsEnabled = config.featureToggles.dashboardNewLayouts; const forcingOldDashboardArch = locationService.getSearch().get('scenes') === 'false'; @@ -39,7 +38,7 @@ export function getDashboardsApiVersion(responseFormat?: 'v1' | 'v2') { if (responseFormat === 'v1') { return 'v1'; } - if (responseFormat === 'v2' || isV2DashboardAPIVersionEnabled || isDashboardNewLayoutsEnabled) { + if (responseFormat === 'v2' || isDashboardNewLayoutsEnabled) { return 'v2'; } return 'unified'; From 9a831ab4e18ef4b2da3ee11ec499ea787a6707bf Mon Sep 17 00:00:00 2001 From: Matheus Macabu Date: Tue, 30 Dec 2025 09:47:00 +0100 Subject: [PATCH 101/229] Auditing: Set default policy rule level for create to req+resp (#115727) Auditing: Set default policy rule level to req+resp --- pkg/apiserver/auditing/policy.go | 15 ++++++++++++--- pkg/apiserver/auditing/policy_test.go | 18 +++++++++++++++++- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/pkg/apiserver/auditing/policy.go b/pkg/apiserver/auditing/policy.go index e88acf7c4cc..ed053ca205d 100644 --- a/pkg/apiserver/auditing/policy.go +++ b/pkg/apiserver/auditing/policy.go @@ -46,14 +46,23 @@ func (defaultGrafanaPolicyRuleEvaluator) EvaluatePolicyRule(attrs authorizer.Att } } + // Logging the response object allows us to get the resource name for create requests. + level := auditinternal.LevelMetadata + if attrs.GetVerb() == utils.VerbCreate { + level = auditinternal.LevelRequestResponse + } + return audit.RequestAuditConfig{ - Level: auditinternal.LevelMetadata, + Level: level, + + // Only log on StageResponseComplete, to avoid noisy logs. OmitStages: []auditinternal.Stage{ - // Only log on StageResponseComplete auditinternal.StageRequestReceived, auditinternal.StageResponseStarted, auditinternal.StagePanic, }, - OmitManagedFields: false, // Setting it to true causes extra copying/unmarshalling. + + // Setting it to true causes extra copying/unmarshalling. + OmitManagedFields: false, } } diff --git a/pkg/apiserver/auditing/policy_test.go b/pkg/apiserver/auditing/policy_test.go index af18f9110fd..ccabaa5e6e2 100644 --- a/pkg/apiserver/auditing/policy_test.go +++ b/pkg/apiserver/auditing/policy_test.go @@ -55,7 +55,7 @@ func TestDefaultGrafanaPolicyRuleEvaluator(t *testing.T) { require.Equal(t, auditinternal.LevelNone, config.Level) }) - t.Run("return audit level metadata for other resource requests", func(t *testing.T) { + t.Run("return audit level request+response for create requests", func(t *testing.T) { t.Parallel() attrs := authorizer.AttributesRecord{ @@ -67,6 +67,22 @@ func TestDefaultGrafanaPolicyRuleEvaluator(t *testing.T) { }, } + config := evaluator.EvaluatePolicyRule(attrs) + require.Equal(t, auditinternal.LevelRequestResponse, config.Level) + }) + + t.Run("return audit level metadata for other resource requests", func(t *testing.T) { + t.Parallel() + + attrs := authorizer.AttributesRecord{ + ResourceRequest: true, + Verb: utils.VerbGet, + User: &user.DefaultInfo{ + Name: "test-user", + Groups: []string{"test-group"}, + }, + } + config := evaluator.EvaluatePolicyRule(attrs) require.Equal(t, auditinternal.LevelMetadata, config.Level) }) From 2dad8b7b5b69d1c63e568a0516765aae99969e39 Mon Sep 17 00:00:00 2001 From: "Marc M." <146180665+grafakus@users.noreply.github.com> Date: Tue, 30 Dec 2025 10:54:00 +0100 Subject: [PATCH 102/229] DynamicDashboards: Add button to feedback form (#114980) --- .../edit-pane/DashboardEditPaneRenderer.tsx | 18 ++++++++++++++++++ public/locales/en-US/grafana.json | 3 +++ 2 files changed, 21 insertions(+) diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx index 7ce42744241..950785c2ffd 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx @@ -83,6 +83,24 @@ export function DashboardEditPaneRenderer({ editPane, dashboard, isDocked }: Pro onClick={() => dashboard.openV2SchemaEditor()} /> */} + + window.open( + 'https://docs.google.com/forms/d/e/1FAIpQLSfDZJM_VlZgRHDx8UPtLWbd9bIBPRxoA28qynTHEYniyPXO6Q/viewform', + '_blank' + ) + } + title={t( + 'dashboard-scene.dashboard-edit-pane-renderer.title-feedback-dashboard-editing-experience', + 'Give feedback on the new dashboard editing experience' + )} + tooltip={t( + 'dashboard-scene.dashboard-edit-pane-renderer.title-feedback-dashboard-editing-experience', + 'Give feedback on the new dashboard editing experience' + )} + /> )} {hasUid && } diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index a51e48d0e7f..7430957c560 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -5967,6 +5967,9 @@ "name-values-separated-comma": "Values separated by comma", "selection-options": "Selection options" }, + "dashboard-edit-pane-renderer": { + "title-feedback-dashboard-editing-experience": "Give feedback on the new dashboard editing experience" + }, "dashboard-link-form": { "back-to-list": "Back to list", "label-icon": "Icon", From 9c3cdd4814929a29df18b7325eedbdbda0feddc8 Mon Sep 17 00:00:00 2001 From: Ezequiel Victorero Date: Tue, 30 Dec 2025 08:46:43 -0300 Subject: [PATCH 103/229] Playlists: Support get with None role (#115713) --- .../apiserver/auth/authorizer/role.go | 2 + pkg/tests/apis/playlist/playlist_test.go | 39 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/pkg/services/apiserver/auth/authorizer/role.go b/pkg/services/apiserver/auth/authorizer/role.go index e8e70dd01c8..63313352571 100644 --- a/pkg/services/apiserver/auth/authorizer/role.go +++ b/pkg/services/apiserver/auth/authorizer/role.go @@ -15,6 +15,8 @@ var _ authorizer.Authorizer = &roleAuthorizer{} var orgRoleNoneAsViewerAPIGroups = []string{ "productactivation.ext.grafana.com", + // playlist can be removed after this issue is resolved: https://github.com/grafana/grafana/issues/115712 + "playlist.grafana.app", } type roleAuthorizer struct{} diff --git a/pkg/tests/apis/playlist/playlist_test.go b/pkg/tests/apis/playlist/playlist_test.go index 2611624debb..da9a6530e5b 100644 --- a/pkg/tests/apis/playlist/playlist_test.go +++ b/pkg/tests/apis/playlist/playlist_test.go @@ -426,6 +426,45 @@ func doPlaylistTests(t *testing.T, helper *apis.K8sTestHelper) *apis.K8sTestHelp require.Equal(t, metav1.StatusReasonForbidden, rsp.Status.Reason) }) + t.Run("Check CRUD operations with None role", func(t *testing.T) { + // Create a playlist with admin user + clientAdmin := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + GVR: gvr, + }) + created, err := clientAdmin.Resource.Create(context.Background(), + helper.LoadYAMLOrJSONFile("testdata/playlist-generate.yaml"), + metav1.CreateOptions{}, + ) + require.NoError(t, err) + + clientNone := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.None, + GVR: gvr, + }) + + // Now check if None user can perform a Get to start a playlist + _, err = clientNone.Resource.Get(context.Background(), created.GetName(), metav1.GetOptions{}) + require.NoError(t, err) + + // None role can get but can not create edit or delete a playlist + _, err = clientNone.Resource.Create(context.Background(), + helper.LoadYAMLOrJSONFile("testdata/playlist-generate.yaml"), + metav1.CreateOptions{}, + ) + require.Error(t, err) + + _, err = clientNone.Resource.Update(context.Background(), created, metav1.UpdateOptions{}) + require.Error(t, err) + + err = clientNone.Resource.Delete(context.Background(), created.GetName(), metav1.DeleteOptions{}) + require.Error(t, err) + + // delete created resource + err = clientAdmin.Resource.Delete(context.Background(), created.GetName(), metav1.DeleteOptions{}) + require.NoError(t, err) + }) + t.Run("Check k8s client-go List from different org users", func(t *testing.T) { // Check Org1 Viewer client := helper.GetResourceClient(apis.ResourceClientArgs{ From 45fc95cfc9672177d12a54da8ab94291ffc79cd5 Mon Sep 17 00:00:00 2001 From: Ezequiel Victorero Date: Tue, 30 Dec 2025 09:54:20 -0300 Subject: [PATCH 104/229] Snapshots: Use settings MT service (#115541) --- .../rtkq/dashboard/v0alpha1/endpoints.gen.ts | 8 ++ pkg/registry/apis/dashboard/register.go | 11 ++- .../apis/dashboard/snapshot/routes.go | 81 +++++++++++++++++++ .../snapshot/snapshot_legacy_store.go | 18 ----- pkg/server/wire_gen.go | 4 +- .../dashboard.grafana.app-v0alpha1.json | 37 +++++++++ .../dashboard/services/SnapshotSrv.ts | 5 +- 7 files changed, 137 insertions(+), 27 deletions(-) diff --git a/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts index b50a074e4a2..326b53ccedd 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts @@ -285,6 +285,10 @@ const injectedRtkApi = api query: (queryArg) => ({ url: `/snapshots/delete/${queryArg.deleteKey}`, method: 'DELETE' }), invalidatesTags: ['Snapshot'], }), + getSnapshotSettings: build.query({ + query: () => ({ url: `/snapshots/settings` }), + providesTags: ['Snapshot'], + }), getSnapshot: build.query({ query: (queryArg) => ({ url: `/snapshots/${queryArg.name}`, @@ -742,6 +746,8 @@ export type DeleteWithKeyApiArg = { /** unique key returned in create */ deleteKey: string; }; +export type GetSnapshotSettingsApiResponse = /** status 200 undefined */ any; +export type GetSnapshotSettingsApiArg = void; export type GetSnapshotApiResponse = /** status 200 OK */ Snapshot; export type GetSnapshotApiArg = { /** name of the Snapshot */ @@ -1273,6 +1279,8 @@ export const { useLazyListSnapshotQuery, useCreateSnapshotMutation, useDeleteWithKeyMutation, + useGetSnapshotSettingsQuery, + useLazyGetSnapshotSettingsQuery, useGetSnapshotQuery, useLazyGetSnapshotQuery, useDeleteSnapshotMutation, diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index eeeb76f924e..eed79dd6f0d 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" + "github.com/grafana/grafana/pkg/configprovider" "github.com/prometheus/client_golang/prometheus" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -62,7 +63,6 @@ import ( "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/search/sort" "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/legacysql" "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" "github.com/grafana/grafana/pkg/storage/unified/apistore" @@ -128,7 +128,6 @@ type DashboardsAPIBuilder struct { } func RegisterAPIService( - cfg *setting.Cfg, features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar, dashboardService dashboards.DashboardService, @@ -154,7 +153,14 @@ func RegisterAPIService( publicDashboardService publicdashboards.Service, snapshotService dashboardsnapshots.Service, dashboardActivityChannel live.DashboardActivityChannel, + configProvider configprovider.ConfigProvider, ) *DashboardsAPIBuilder { + cfg, err := configProvider.Get(context.Background()) + if err != nil { + logging.DefaultLogger.Error("failed to load settings configuration instance", "stackId", cfg.StackID, "err", err) + return nil + } + dbp := legacysql.NewDatabaseProvider(sql) namespacer := request.GetNamespaceMapper(cfg) legacyDashboardSearcher := legacysearcher.NewDashboardSearchClient(dashStore, sorter) @@ -747,7 +753,6 @@ func (b *DashboardsAPIBuilder) storageForVersion( ResourceInfo: *snapshots, Service: b.snapshotService, Namespacer: b.namespacer, - Options: b.snapshotOptions, } storage[snapshots.StoragePath()] = snapshotLegacyStore storage[snapshots.StoragePath("dashboard")], err = snapshot.NewDashboardREST(dashboards, b.snapshotService) diff --git a/pkg/registry/apis/dashboard/snapshot/routes.go b/pkg/registry/apis/dashboard/snapshot/routes.go index c8175d6d9dd..832589f5c68 100644 --- a/pkg/registry/apis/dashboard/snapshot/routes.go +++ b/pkg/registry/apis/dashboard/snapshot/routes.go @@ -29,6 +29,8 @@ func GetRoutes(service dashboardsnapshots.Service, options dashv0.SnapshotSharin createCmd := defs["github.com/grafana/grafana/apps/dashboard/pkg/apissnapshot/v0alpha1.DashboardCreateCommand"].Schema createExample := `{"dashboard":{"annotations":{"list":[{"name":"Annotations & Alerts","enable":true,"iconColor":"rgba(0, 211, 255, 1)","snapshotData":[],"type":"dashboard","builtIn":1,"hide":true}]},"editable":true,"fiscalYearStartMonth":0,"graphTooltip":0,"id":203,"links":[],"liveNow":false,"panels":[{"datasource":null,"fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"overrides":[]},"gridPos":{"h":8,"w":12,"x":0,"y":0},"id":1,"options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom","showLegend":true},"tooltip":{"mode":"single","sort":"none"}},"pluginVersion":"10.4.0-pre","snapshotData":[{"fields":[{"config":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"showPoints":"auto","thresholdsStyle":{"mode":"off"}},"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"name":"time","type":"time","values":[1706030536378,1706034856378,1706039176378,1706043496378,1706047816378,1706052136378]},{"config":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"name":"A-series","type":"number","values":[1,20,90,30,50,0]}],"refId":"A"}],"targets":[],"title":"Simple example","type":"timeseries","links":[]}],"refresh":"","schemaVersion":39,"snapshot":{"timestamp":"2024-01-23T23:22:16.377Z"},"tags":[],"templating":{"list":[]},"time":{"from":"2024-01-23T17:22:20.380Z","to":"2024-01-23T23:22:20.380Z","raw":{"from":"now-6h","to":"now"}},"timepicker":{},"timezone":"","title":"simple and small","uid":"b22ec8db-399b-403b-b6c7-b0fb30ccb2a5","version":1,"weekStart":""},"name":"simple and small","expires":86400}` createRsp := defs["github.com/grafana/grafana/apps/dashboard/pkg/apissnapshot/v0alpha1.DashboardCreateResponse"].Schema + getSettingsRsp := defs["github.com/grafana/grafana/apps/dashboard/pkg/apissnapshot/v0alpha1.SnapshotSharingOptions"].Schema + getSettingsRspExample := `{"snapshotsEnabled":true,"externalSnapshotURL":"https://externalurl.com","externalSnapshotName":"external","externalEnabled":true}` return &builder.APIRoutes{ Namespace: []builder.APIRouteHandler{ @@ -167,5 +169,84 @@ func GetRoutes(service dashboardsnapshots.Service, options dashv0.SnapshotSharin }) }, }, + { + Path: prefix + "/settings", + Spec: &spec3.PathProps{ + Get: &spec3.Operation{ + VendorExtensible: spec.VendorExtensible{ + Extensions: map[string]any{ + "x-grafana-action": "get", + "x-kubernetes-group-version-kind": metav1.GroupVersionKind{ + Group: dashv0.GROUP, + Version: dashv0.VERSION, + Kind: "SnapshotSharingOptions", + }, + }, + }, + OperationProps: spec3.OperationProps{ + Tags: tags, + OperationId: "getSnapshotSettings", + Description: "Get Snapshot sharing settings", + Parameters: []*spec3.Parameter{ + { + ParameterProps: spec3.ParameterProps{ + Name: "namespace", + In: "path", + Required: true, + Example: "default", + Description: "workspace", + Schema: spec.StringProperty(), + }, + }, + }, + Responses: &spec3.Responses{ + ResponsesProps: spec3.ResponsesProps{ + StatusCodeResponses: map[int]*spec3.Response{ + 200: { + ResponseProps: spec3.ResponseProps{ + Content: map[string]*spec3.MediaType{ + "application/json": { + MediaTypeProps: spec3.MediaTypeProps{ + Schema: &getSettingsRsp, + Example: getSettingsRspExample, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + Handler: func(w http.ResponseWriter, r *http.Request) { + user, err := identity.GetRequester(r.Context()) + if err != nil { + errhttp.Write(r.Context(), err, w) + return + } + wrap := &contextmodel.ReqContext{ + Context: &web.Context{ + Req: r, + Resp: web.NewResponseWriter(r.Method, w), + }, + } + + vars := mux.Vars(r) + info, err := authlib.ParseNamespace(vars["namespace"]) + if err != nil { + wrap.JsonApiErr(http.StatusBadRequest, "expected namespace", nil) + return + } + if info.OrgID != user.GetOrgID() { + wrap.JsonApiErr(http.StatusBadRequest, + fmt.Sprintf("user orgId does not match namespace (%d != %d)", info.OrgID, user.GetOrgID()), nil) + return + } + + wrap.JSON(http.StatusOK, options) + }, + }, }} } diff --git a/pkg/registry/apis/dashboard/snapshot/snapshot_legacy_store.go b/pkg/registry/apis/dashboard/snapshot/snapshot_legacy_store.go index aafbc2b283d..7ba2d4228c5 100644 --- a/pkg/registry/apis/dashboard/snapshot/snapshot_legacy_store.go +++ b/pkg/registry/apis/dashboard/snapshot/snapshot_legacy_store.go @@ -2,7 +2,6 @@ package snapshot import ( "context" - "fmt" "k8s.io/apimachinery/pkg/apis/meta/internalversion" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -29,7 +28,6 @@ type SnapshotLegacyStore struct { ResourceInfo utils.ResourceInfo Service dashboardsnapshots.Service Namespacer request.NamespaceMapper - Options dashV0.SnapshotSharingOptions } func (s *SnapshotLegacyStore) New() runtime.Object { @@ -117,15 +115,6 @@ func (s *SnapshotLegacyStore) List(ctx context.Context, options *internalversion } func (s *SnapshotLegacyStore) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { - info, err := request.NamespaceInfoFrom(ctx, true) - if err != nil { - return nil, err - } - - err = s.checkEnabled(info.Value) - if err != nil { - return nil, err - } query := dashboardsnapshots.GetDashboardSnapshotQuery{ Key: name, } @@ -140,10 +129,3 @@ func (s *SnapshotLegacyStore) Get(ctx context.Context, name string, options *met } return nil, s.ResourceInfo.NewNotFound(name) } - -func (s *SnapshotLegacyStore) checkEnabled(ns string) error { - if !s.Options.SnapshotsEnabled { - return fmt.Errorf("snapshots not enabled") - } - return nil -} diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 676b0605e83..b958e5f7ad9 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -875,7 +875,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api ldapImpl := service12.ProvideService(cfg, featureToggles, ssosettingsimplService) apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService) dashboardActivityChannel := live.ProvideDashboardActivityChannel(grafanaLive) - dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl, serviceImpl, dashboardActivityChannel) + dashboardsAPIBuilder := dashboard.RegisterAPIService(featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl, serviceImpl, dashboardActivityChannel, configProvider) dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer, sourcesService) if err != nil { return nil, err @@ -1537,7 +1537,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac ldapImpl := service12.ProvideService(cfg, featureToggles, ssosettingsimplService) apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService) dashboardActivityChannel := live.ProvideDashboardActivityChannel(grafanaLive) - dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl, serviceImpl, dashboardActivityChannel) + dashboardsAPIBuilder := dashboard.RegisterAPIService(featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl, serviceImpl, dashboardActivityChannel, configProvider) dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer, sourcesService) if err != nil { return nil, err diff --git a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json index 61834093866..4634143bd45 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json @@ -2169,6 +2169,43 @@ ] } }, + "/apis/dashboard.grafana.app/v0alpha1/namespaces/{namespace}/snapshots/settings": { + "get": { + "tags": [ + "Snapshot" + ], + "description": "Get Snapshot sharing settings", + "operationId": "getSnapshotSettings", + "parameters": [ + { + "name": "namespace", + "in": "path", + "description": "workspace", + "required": true, + "schema": { + "type": "string" + }, + "example": "default" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {}, + "example": "{\"snapshotsEnabled\":true,\"externalSnapshotURL\":\"https://externalurl.com\",\"externalSnapshotName\":\"external\",\"externalEnabled\":true}" + } + } + } + }, + "x-grafana-action": "get", + "x-kubernetes-group-version-kind": { + "group": "dashboard.grafana.app", + "version": "v0alpha1", + "kind": "SnapshotSharingOptions" + } + } + }, "/apis/dashboard.grafana.app/v0alpha1/namespaces/{namespace}/snapshots/{name}": { "get": { "tags": [ diff --git a/public/app/features/dashboard/services/SnapshotSrv.ts b/public/app/features/dashboard/services/SnapshotSrv.ts index 276f9d717df..ba74866499a 100644 --- a/public/app/features/dashboard/services/SnapshotSrv.ts +++ b/public/app/features/dashboard/services/SnapshotSrv.ts @@ -118,10 +118,7 @@ class K8sAPI implements DashboardSnapshotSrv { } async getSharingOptions() { - // TODO? should this be in a config service, or in the same service? - // we have http://localhost:3000/apis/dashboardsnapshot.grafana.app/v0alpha1/namespaces/default/options - // BUT that has an unclear user mapping story still, so lets stick with the existing shared-options endpoint - return getBackendSrv().get('/api/snapshot/shared-options'); + return getBackendSrv().get(this.url + '/settings'); } async getSnapshot(uid: string): Promise { From 75b2c905cd2f117b6d98c4d4a7fed0fef0f1df62 Mon Sep 17 00:00:00 2001 From: Matheus Macabu Date: Tue, 30 Dec 2025 14:05:23 +0100 Subject: [PATCH 105/229] Auditing: Move sinkable/logger interfaces and add global default logger implementation (#115743) * Auditing: Move sinkable and logger interfaces * Auditing: Add global default logger implementation * Chore: Fix enterprise imports --- go.mod | 2 +- pkg/apiserver/auditing/logger.go | 55 ++++++++++++++++++++++++++++ pkg/apiserver/auditing/noop.go | 15 +++++++- pkg/extensions/enterprise_imports.go | 6 +-- 4 files changed, 72 insertions(+), 6 deletions(-) create mode 100644 pkg/apiserver/auditing/logger.go diff --git a/go.mod b/go.mod index b848514cea4..f22d410c51f 100644 --- a/go.mod +++ b/go.mod @@ -52,7 +52,7 @@ require ( github.com/crewjam/saml v0.4.14 // @grafana/identity-access-team github.com/dgraph-io/badger/v4 v4.7.0 // @grafana/grafana-search-and-storage github.com/dlmiddlecote/sqlstats v1.0.2 // @grafana/grafana-backend-group - github.com/docker/go-connections v0.6.0 // indirect; @grafana/grafana-app-platform-squad + github.com/docker/go-connections v0.6.0 // @grafana/grafana-app-platform-squad github.com/dolthub/go-mysql-server v0.19.1-0.20250410182021-5632d67cd46e // @grafana/grafana-datasources-core-services github.com/dolthub/vitess v0.0.0-20250930230441-70c2c6a98e33 // @grafana/grafana-datasources-core-services github.com/dustin/go-humanize v1.0.1 // @grafana/observability-traces-and-profiling diff --git a/pkg/apiserver/auditing/logger.go b/pkg/apiserver/auditing/logger.go new file mode 100644 index 00000000000..8e60d463255 --- /dev/null +++ b/pkg/apiserver/auditing/logger.go @@ -0,0 +1,55 @@ +package auditing + +import ( + "context" + "encoding/json" + "time" +) + +// Sinkable is a log entry abstraction that can be sent to an audit log sink through the different implementing methods. +type Sinkable interface { + json.Marshaler + KVPairs() []any + Time() time.Time +} + +// Logger specifies the contract for a specific audit logger. +type Logger interface { + Log(entry Sinkable) error + Close() error + Type() string +} + +// Implementation inspired by https://github.com/grafana/grafana-app-sdk/blob/main/logging/logger.go +type loggerContextKey struct{} + +var ( + // DefaultLogger is the default Logger if one hasn't been provided in the context. + // You may use this to add arbitrary audit logging outside of an API request lifecycle. + DefaultLogger Logger = &NoopLogger{} + + contextKey = loggerContextKey{} +) + +// FromContext returns the Logger set in the context with Context(), or the DefaultLogger if no Logger is set in the context. +// If DefaultLogger is nil, it returns a *NoopLogger so that the return is always valid to call methods on without nil-checking. +// You may use this to add arbitrary audit logging outside of an API request lifecycle. +func FromContext(ctx context.Context) Logger { + if l := ctx.Value(contextKey); l != nil { + if logger, ok := l.(Logger); ok { + return logger + } + } + + if DefaultLogger != nil { + return DefaultLogger + } + + return &NoopLogger{} +} + +// Context returns a new context built from the provided context with the provided logger in it. +// The Logger added with Context() can be retrieved with FromContext() +func Context(ctx context.Context, logger Logger) context.Context { + return context.WithValue(ctx, contextKey, logger) +} diff --git a/pkg/apiserver/auditing/noop.go b/pkg/apiserver/auditing/noop.go index 5a6b39a3b71..c36c3577a09 100644 --- a/pkg/apiserver/auditing/noop.go +++ b/pkg/apiserver/auditing/noop.go @@ -11,9 +11,9 @@ type NoopBackend struct{} func ProvideNoopBackend() audit.Backend { return &NoopBackend{} } -func (b *NoopBackend) ProcessEvents(k8sEvents ...*auditinternal.Event) bool { return false } +func (NoopBackend) ProcessEvents(...*auditinternal.Event) bool { return false } -func (NoopBackend) Run(stopCh <-chan struct{}) error { return nil } +func (NoopBackend) Run(<-chan struct{}) error { return nil } func (NoopBackend) Shutdown() {} @@ -34,3 +34,14 @@ type NoopPolicyRuleEvaluator struct{} func (NoopPolicyRuleEvaluator) EvaluatePolicyRule(authorizer.Attributes) audit.RequestAuditConfig { return audit.RequestAuditConfig{Level: auditinternal.LevelNone} } + +// NoopLogger is a no-op implementation of Logger +type NoopLogger struct{} + +func ProvideNoopLogger() Logger { return &NoopLogger{} } + +func (NoopLogger) Type() string { return "noop" } + +func (NoopLogger) Log(Sinkable) error { return nil } + +func (NoopLogger) Close() error { return nil } diff --git a/pkg/extensions/enterprise_imports.go b/pkg/extensions/enterprise_imports.go index 113c2f8e4bb..472652cc103 100644 --- a/pkg/extensions/enterprise_imports.go +++ b/pkg/extensions/enterprise_imports.go @@ -15,6 +15,7 @@ import ( _ "github.com/blugelabs/bluge" _ "github.com/blugelabs/bluge_segment_api" _ "github.com/crewjam/saml" + _ "github.com/docker/go-connections/nat" _ "github.com/go-jose/go-jose/v4" _ "github.com/gobwas/glob" _ "github.com/googleapis/gax-go/v2" @@ -30,6 +31,7 @@ import ( _ "github.com/spf13/cobra" // used by the standalone apiserver cli _ "github.com/spyzhov/ajson" _ "github.com/stretchr/testify/require" + _ "github.com/testcontainers/testcontainers-go" _ "gocloud.dev/secrets/awskms" _ "gocloud.dev/secrets/azurekeyvault" _ "gocloud.dev/secrets/gcpkms" @@ -54,9 +56,7 @@ import ( _ "github.com/grafana/e2e" _ "github.com/grafana/gofpdf" _ "github.com/grafana/gomemcache/memcache" - _ "github.com/grafana/tempo/pkg/traceql" - _ "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1" _ "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1" - _ "github.com/testcontainers/testcontainers-go" + _ "github.com/grafana/tempo/pkg/traceql" ) From e7625186af89454eb60f03e4702fb9aa85df4a67 Mon Sep 17 00:00:00 2001 From: Ayush Kaithwas Date: Tue, 30 Dec 2025 20:05:43 +0530 Subject: [PATCH 106/229] Dashboards: Clear edit pane selection when entering panel edit (#115658) * Clear selection on entering edit mode. Added test to verify selection is cleared when editing a panel. * Update comment --------- Co-authored-by: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> --- .../panel-edit/PanelEditor.test.ts | 31 +++++++++++++++++++ .../panel-edit/PanelEditor.tsx | 5 +++ 2 files changed, 36 insertions(+) diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts b/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts index ee2bda935fd..89634322347 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts @@ -112,6 +112,37 @@ describe('PanelEditor', () => { }); }); + describe('Entering panel edit', () => { + it('should clear edit pane selection', () => { + pluginPromise = Promise.resolve(getPanelPlugin({ id: 'text', skipDataQuery: true })); + + const panel = new VizPanel({ + key: 'panel-1', + pluginId: 'text', + title: 'original title', + }); + const gridItem = new DashboardGridItem({ body: panel }); + const panelEditor = buildPanelEditScene(panel); + const dashboard = new DashboardScene({ + editPanel: panelEditor, + isEditing: true, + $timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }), + body: new DefaultGridLayoutManager({ + grid: new SceneGridLayout({ + children: [gridItem], + }), + }), + }); + + dashboard.state.editPane.selectObject(panel, panel.state.key!, { force: true }); + expect(dashboard.state.editPane.getSelection()).toBe(panel); + + deactivate = activateFullSceneTree(dashboard); + + expect(dashboard.state.editPane.getSelection()).toBeUndefined(); + }); + }); + describe('When discarding', () => { it('should discard changes revert all changes', async () => { const { panelEditor, panel, dashboard } = await setup(); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx index e656a39e6a1..497d58e505a 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx @@ -84,6 +84,11 @@ export class PanelEditor extends SceneObjectBase { private _activationHandler() { const panel = this.state.panelRef.resolve(); + const dashboard = getDashboardSceneFor(this); + + // Clear any panel selection when entering panel edit mode. + // Need to clear selection here since selection is activated when panel edit mode is entered through the panel actions menu. This causes sidebar panel editor to be open when exiting panel edit mode + dashboard.state.editPane.clearSelection(); if (panel.state.pluginId === UNCONFIGURED_PANEL_PLUGIN_ID) { if (config.featureToggles.newVizSuggestions) { From 9c6feb8de5fb5adf0304b79b88fc03917ff5b177 Mon Sep 17 00:00:00 2001 From: Andrew Hackmann <5140848+bossinc@users.noreply.github.com> Date: Tue, 30 Dec 2025 09:37:19 -0600 Subject: [PATCH 107/229] Elasticsearch: Builder queries no longer execute in code mode (#115456) * The builder query no longer runs if code mode query is empty. Remove checks for query being empty to run raw query. * missed save * prettier? * Update public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts Co-authored-by: Andreas Christou --------- Co-authored-by: Andreas Christou --- .../elasticsearch/data_query_processor.go | 2 +- .../elasticsearch/data_query_validator.go | 2 +- .../state/reducer.test.ts | 25 ++++++++++- .../BucketAggregationsEditor/state/reducer.ts | 7 ++- .../state/reducer.test.ts | 24 ++++++++++- .../MetricAggregationsEditor/state/reducer.ts | 7 ++- .../components/QueryEditor/state.test.ts | 43 ++++++++++++++++++- .../components/QueryEditor/state.ts | 4 ++ 8 files changed, 107 insertions(+), 7 deletions(-) diff --git a/pkg/tsdb/elasticsearch/data_query_processor.go b/pkg/tsdb/elasticsearch/data_query_processor.go index 1c4ec7b3cdd..288d6ce30de 100644 --- a/pkg/tsdb/elasticsearch/data_query_processor.go +++ b/pkg/tsdb/elasticsearch/data_query_processor.go @@ -24,7 +24,7 @@ func (e *elasticsearchDataQuery) processQuery(q *Query, ms *es.MultiSearchReques filters.AddDateRangeFilter(defaultTimeField, to, from, es.DateFormatEpochMS) filters.AddQueryStringFilter(q.RawQuery, true) - if q.EditorType != nil && *q.EditorType == "code" && q.RawDSLQuery != "" { + if q.EditorType != nil && *q.EditorType == "code" { cfg := backend.GrafanaConfigFromContext(e.ctx) if !cfg.FeatureToggles().IsEnabled("elasticsearchRawDSLQuery") { return backend.DownstreamError(fmt.Errorf("raw DSL query feature is disabled. Enable the elasticsearchRawDSLQuery feature toggle to use this query type")) diff --git a/pkg/tsdb/elasticsearch/data_query_validator.go b/pkg/tsdb/elasticsearch/data_query_validator.go index 648dbb53109..72bcde016b6 100644 --- a/pkg/tsdb/elasticsearch/data_query_validator.go +++ b/pkg/tsdb/elasticsearch/data_query_validator.go @@ -7,7 +7,7 @@ import ( // isQueryWithError validates the query and returns an error if invalid func isQueryWithError(query *Query) error { // Skip validation for raw DSL queries because no easy way to see it is valid without just running it - if query.EditorType != nil && *query.EditorType == "code" && query.RawDSLQuery != "" { + if query.EditorType != nil && *query.EditorType == "code" { return nil } if len(query.BucketAggs) == 0 { diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts index 6a34d5d7d91..f4a5cc02dde 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts @@ -7,7 +7,7 @@ import { import { defaultBucketAgg } from '../../../../queryDef'; import { reducerTester } from '../../../reducerTester'; import { changeMetricType } from '../../MetricAggregationsEditor/state/actions'; -import { initQuery } from '../../state'; +import { changeEditorTypeAndResetQuery, initQuery } from '../../state'; import { bucketAggregationConfig } from '../utils'; import { @@ -180,4 +180,27 @@ describe('Bucket Aggregations Reducer', () => { .thenStateShouldEqual([bucketAgg]); }); }); + + describe('When switching editor type', () => { + it('Should reset bucket aggregations to default when switching editor types', () => { + const defaultTimeField = '@timestamp'; + const initialState: BucketAggregation[] = [ + { + id: '1', + type: 'date_histogram', + field: '@timestamp', + }, + { + id: '2', + type: 'terms', + field: 'status', + }, + ]; + + reducerTester() + .givenReducer(createReducer(defaultTimeField), initialState) + .whenActionIsDispatched(changeEditorTypeAndResetQuery('code')) + .thenStateShouldEqual([{ ...defaultBucketAgg('2'), field: defaultTimeField }]); + }); + }); }); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts index b3638e1f1d1..5ba29e656d8 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts @@ -6,7 +6,7 @@ import { defaultBucketAgg } from '../../../../queryDef'; import { removeEmpty } from '../../../../utils'; import { changeMetricType } from '../../MetricAggregationsEditor/state/actions'; import { metricAggregationConfig } from '../../MetricAggregationsEditor/utils'; -import { initQuery } from '../../state'; +import { changeEditorTypeAndResetQuery, initQuery } from '../../state'; import { bucketAggregationConfig } from '../utils'; import { @@ -87,6 +87,11 @@ export const createReducer = return state; } + if (changeEditorTypeAndResetQuery.match(action)) { + // Returns the default bucket agg. We will always want to set the default when switching types + return [{ ...defaultBucketAgg('2'), field: defaultTimeField }]; + } + if (changeBucketAggregationSetting.match(action)) { return state!.map((bucketAgg) => { if (bucketAgg.id !== action.payload.bucketAgg.id) { diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts index 5662ad399ea..9dcbaa9f974 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts @@ -7,7 +7,7 @@ import { import { defaultMetricAgg } from '../../../../queryDef'; import { reducerTester } from '../../../reducerTester'; -import { initQuery } from '../../state'; +import { changeEditorTypeAndResetQuery, initQuery } from '../../state'; import { metricAggregationConfig } from '../utils'; import { @@ -248,4 +248,26 @@ describe('Metric Aggregations Reducer', () => { .whenActionIsDispatched(initQuery()) .thenStateShouldEqual([defaultMetricAgg('1')]); }); + + describe('When switching editor type', () => { + it('Should reset to single default metric when switching to code editor', () => { + const initialState: MetricAggregation[] = [ + { + id: '1', + type: 'avg', + field: 'value', + }, + { + id: '2', + type: 'max', + field: 'value', + }, + ]; + + reducerTester() + .givenReducer(reducer, initialState) + .whenActionIsDispatched(changeEditorTypeAndResetQuery('code')) + .thenStateShouldEqual([defaultMetricAgg('1')]); + }); + }); }); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts index 966bd71d6c8..c0dab7bd4b1 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts @@ -4,7 +4,7 @@ import { ElasticsearchDataQuery, MetricAggregation } from 'app/plugins/datasourc import { defaultMetricAgg, queryTypeToMetricType } from '../../../../queryDef'; import { removeEmpty } from '../../../../utils'; -import { initQuery } from '../../state'; +import { changeEditorTypeAndResetQuery, initQuery } from '../../state'; import { isMetricAggregationWithMeta, isMetricAggregationWithSettings, isPipelineAggregation } from '../aggregations'; import { getChildren, metricAggregationConfig } from '../utils'; @@ -65,6 +65,11 @@ export const reducer = ( }); } + if (changeEditorTypeAndResetQuery.match(action)) { + // Reset to default metric when switching to editor types + return [defaultMetricAgg('1')]; + } + if (changeMetricField.match(action)) { return state!.map((metric) => { if (metric.id !== action.payload.id) { diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.test.ts index cad89cd32a7..111b284eb79 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.test.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.test.ts @@ -1,7 +1,15 @@ import { ElasticsearchDataQuery } from '../../dataquery.gen'; import { reducerTester } from '../reducerTester'; -import { aliasPatternReducer, changeAliasPattern, changeQuery, initQuery, queryReducer } from './state'; +import { + aliasPatternReducer, + changeAliasPattern, + changeEditorTypeAndResetQuery, + changeQuery, + initQuery, + queryReducer, + rawDSLQueryReducer, +} from './state'; describe('Query Reducer', () => { describe('On Init', () => { @@ -42,6 +50,17 @@ describe('Query Reducer', () => { .whenActionIsDispatched({ type: 'THIS ACTION SHOULD NOT HAVE ANY EFFECT IN THIS REDUCER' }) .thenStateShouldEqual(initialState); }); + + describe('When switching editor type', () => { + it('Should clear query when switching editor types', () => { + const initialQuery: ElasticsearchDataQuery['query'] = 'Some lucene query'; + + reducerTester() + .givenReducer(queryReducer, initialQuery) + .whenActionIsDispatched(changeEditorTypeAndResetQuery('code')) + .thenStateShouldEqual(''); + }); + }); }); describe('Alias Pattern Reducer', () => { @@ -62,4 +81,26 @@ describe('Alias Pattern Reducer', () => { .whenActionIsDispatched({ type: 'THIS ACTION SHOULD NOT HAVE ANY EFFECT IN THIS REDUCER' }) .thenStateShouldEqual(initialState); }); + + describe('When switching editor type', () => { + it('Should clear alias when switching editor types', () => { + const initialAlias: ElasticsearchDataQuery['alias'] = 'Some alias pattern'; + + reducerTester() + .givenReducer(aliasPatternReducer, initialAlias) + .whenActionIsDispatched(changeEditorTypeAndResetQuery('code')) + .thenStateShouldEqual(''); + }); + }); +}); + +describe('Raw DSL Query Reducer', () => { + it('Should clear raw DSL query when switching editor types', () => { + const initialRawQuery: ElasticsearchDataQuery['rawDSLQuery'] = '{"query": {"match_all": {}}}'; + + reducerTester() + .givenReducer(rawDSLQueryReducer, initialRawQuery) + .whenActionIsDispatched(changeEditorTypeAndResetQuery('builder')) + .thenStateShouldEqual(''); + }); }); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts index a9ed51b39ff..5a1be7be31c 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts @@ -58,6 +58,10 @@ export const aliasPatternReducer = (prevAliasPattern: ElasticsearchDataQuery['al return action.payload; } + if (changeEditorTypeAndResetQuery.match(action)) { + return ''; + } + if (initQuery.match(action)) { return prevAliasPattern || ''; } From d291dfb35b324f12f55ebf34dc97be36d8e27f1c Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Tue, 30 Dec 2025 08:51:46 -0700 Subject: [PATCH 108/229] Dashboard Conversion: Fix type assertion mismatch in data loss detection (#115749) --- .../conversion_data_loss_detection.go | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/apps/dashboard/pkg/migration/conversion/conversion_data_loss_detection.go b/apps/dashboard/pkg/migration/conversion/conversion_data_loss_detection.go index db3353b66a1..269fb51bd70 100644 --- a/apps/dashboard/pkg/migration/conversion/conversion_data_loss_detection.go +++ b/apps/dashboard/pkg/migration/conversion/conversion_data_loss_detection.go @@ -180,12 +180,15 @@ func countAnnotationsV0V1(spec map[string]interface{}) int { return 0 } - annotationList, ok := annotations["list"].([]interface{}) - if !ok { - return 0 + // Handle both []interface{} (from JSON unmarshaling) and []map[string]interface{} (from programmatic creation) + if annotationList, ok := annotations["list"].([]interface{}); ok { + return len(annotationList) + } + if annotationList, ok := annotations["list"].([]map[string]interface{}); ok { + return len(annotationList) } - return len(annotationList) + return 0 } // countLinksV0V1 counts dashboard links in v0alpha1 or v1beta1 dashboard spec @@ -194,12 +197,15 @@ func countLinksV0V1(spec map[string]interface{}) int { return 0 } - links, ok := spec["links"].([]interface{}) - if !ok { - return 0 + // Handle both []interface{} (from JSON unmarshaling) and []map[string]interface{} (from programmatic creation) + if links, ok := spec["links"].([]interface{}); ok { + return len(links) + } + if links, ok := spec["links"].([]map[string]interface{}); ok { + return len(links) } - return len(links) + return 0 } // countVariablesV0V1 counts template variables in v0alpha1 or v1beta1 dashboard spec @@ -213,12 +219,15 @@ func countVariablesV0V1(spec map[string]interface{}) int { return 0 } - variableList, ok := templating["list"].([]interface{}) - if !ok { - return 0 + // Handle both []interface{} (from JSON unmarshaling) and []map[string]interface{} (from programmatic creation) + if variableList, ok := templating["list"].([]interface{}); ok { + return len(variableList) + } + if variableList, ok := templating["list"].([]map[string]interface{}); ok { + return len(variableList) } - return len(variableList) + return 0 } // collectStatsV0V1 collects statistics from v0alpha1 or v1beta1 dashboard From 52698cf0da5d07eeef04398d9c5cbd2c57a4c3ad Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Tue, 30 Dec 2025 10:55:40 -0500 Subject: [PATCH 109/229] Sparkline: Restore to a function component (#115447) * Sparkline: Restore to a function component * fix whitespace lint issue --- .../src/components/Sparkline/Sparkline.tsx | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx index d1fb4f3b0e0..a9d3f039c42 100644 --- a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx +++ b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx @@ -17,8 +17,9 @@ export interface SparklineProps extends Themeable2 { showHighlights?: boolean; } -export const SparklineFn: React.FC = memo((props) => { +export const Sparkline: React.FC = memo((props) => { const { sparkline, config: fieldConfig, theme, width, height, showHighlights } = props; + const { frame: alignedDataFrame, warning } = prepareSeries(sparkline, theme, fieldConfig, showHighlights); if (warning) { return null; @@ -30,14 +31,4 @@ export const SparklineFn: React.FC = memo((props) => { return ; }); -SparklineFn.displayName = 'Sparkline'; - -// we converted to function component above, but some apps extend Sparkline, so we need -// to keep exporting a class component until those apps are all rolled out. -// see https://github.com/grafana/app-observability-plugin/pull/2079 -// eslint-disable-next-line react-prefer-function-component/react-prefer-function-component -export class Sparkline extends React.PureComponent { - render() { - return ; - } -} +Sparkline.displayName = 'Sparkline'; From 82b4ce0ece684c46ba1d749a939fbbaee8627bf7 Mon Sep 17 00:00:00 2001 From: Sean Griffin Date: Tue, 30 Dec 2025 11:46:29 -0500 Subject: [PATCH 110/229] Redesign Empty Transformation Panel (#115648) Co-authored-by: Alex Spencer <52186778+alexjonspencer1@users.noreply.github.com> --- .../EmptyTransformationsMessage.tsx | 47 ++++---- .../SqlExpressionCard.tsx | 62 ++-------- .../TransformationCard.tsx | 106 ++++-------------- .../TransformationPickerNg.tsx | 9 +- .../TransformationsEditor/getCardStyles.ts | 34 ++++++ 5 files changed, 96 insertions(+), 162 deletions(-) create mode 100644 public/app/features/dashboard/components/TransformationsEditor/getCardStyles.ts diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.tsx index eab5f3e9c58..1e8ff639785 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.tsx @@ -4,7 +4,7 @@ import { DataFrame, DataTransformerID, standardTransformersRegistry, Transformer import { selectors } from '@grafana/e2e-selectors'; import { t, Trans } from '@grafana/i18n'; import { reportInteraction } from '@grafana/runtime'; -import { Box, Button, Grid, Stack, Text } from '@grafana/ui'; +import { Box, Button, Stack, Text } from '@grafana/ui'; import config from 'app/core/config'; import { SqlExpressionCard } from '../../../dashboard/components/TransformationsEditor/SqlExpressionCard'; @@ -26,9 +26,6 @@ const TRANSFORMATION_IDS = [ DataTransformerID.filterByValue, ]; -const GRID_COLUMNS_WITH_SQL = 5; -const GRID_COLUMNS_WITHOUT_SQL = 4; - export function LegacyEmptyTransformationsMessage({ onShowPicker }: { onShowPicker: () => void }) { return ( @@ -94,13 +91,25 @@ export function NewEmptyTransformationsMessage(props: EmptyTransformationsProps) }; const showSqlCard = hasGoToQueries && config.featureToggles.sqlExpressions; - const gridColumns = showSqlCard ? GRID_COLUMNS_WITH_SQL : GRID_COLUMNS_WITHOUT_SQL; return ( - - + + + + + Add a Transformation + + + + Transformations allow data to be changed in various ways before your visualization is shown. +
+ This includes joining data together, renaming fields, making calculations, formatting data for display, + and more. +
+
+
{(hasAddTransformation || hasGoToQueries) && ( - + {showSqlCard && ( ))} - +
)} - - - +
); diff --git a/public/app/features/dashboard/components/TransformationsEditor/SqlExpressionCard.tsx b/public/app/features/dashboard/components/TransformationsEditor/SqlExpressionCard.tsx index 0cb9302df2e..5f9712897b8 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/SqlExpressionCard.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/SqlExpressionCard.tsx @@ -1,7 +1,6 @@ -import { css } from '@emotion/css'; +import { Card, Text, useStyles2 } from '@grafana/ui'; -import { GrafanaTheme2 } from '@grafana/data'; -import { Card, useStyles2 } from '@grafana/ui'; +import { getCardStyles } from './getCardStyles'; export interface SqlExpressionCardProps { name: string; @@ -12,60 +11,15 @@ export interface SqlExpressionCardProps { } export function SqlExpressionCard({ name, description, imageUrl, onClick, testId }: SqlExpressionCardProps) { - const styles = useStyles2(getSqlExpressionCardStyles); + const styles = useStyles2(getCardStyles); return ( - - -
- {name} -
-
- - {description} - {imageUrl && ( - - {name} - - )} + + {name} + + {description} + {imageUrl && {name}} ); } - -function getSqlExpressionCardStyles(theme: GrafanaTheme2) { - return { - card: css({ - gridTemplateRows: 'min-content 0 1fr 0', - marginBottom: 0, - }), - heading: css({ - fontWeight: 400, - '> button': { - width: '100%', - display: 'flex', - flexDirection: 'column', - alignItems: 'flex-start', - gap: theme.spacing(1), - }, - }), - titleRow: css({ - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - flexWrap: 'nowrap', - width: '100%', - }), - description: css({ - fontSize: theme.typography.bodySmall.fontSize, - display: 'flex', - flexDirection: 'column', - justifyContent: 'space-between', - }), - image: css({ - display: 'block', - maxWidth: '100%', - marginTop: theme.spacing(2), - }), - }; -} diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationCard.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationCard.tsx index ad113f1b227..8e909480f74 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/TransformationCard.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationCard.tsx @@ -1,35 +1,38 @@ -import { cx, css } from '@emotion/css'; +import { cx } from '@emotion/css'; import { DataFrame, - GrafanaTheme2, TransformerRegistryItem, TransformationApplicabilityLevels, standardTransformersRegistry, } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { Badge, Card, IconButton, useStyles2, useTheme2 } from '@grafana/ui'; +import { Badge, Card, IconButton, Stack, Text, useStyles2, useTheme2 } from '@grafana/ui'; import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo'; +import { getCardStyles } from './getCardStyles'; + export interface TransformationCardProps { - transform: TransformerRegistryItem; + data?: DataFrame[]; + fullWidth?: boolean; onClick: (id: string) => void; showIllustrations?: boolean; - data?: DataFrame[]; showPluginState?: boolean; showTags?: boolean; + transform: TransformerRegistryItem; } export function TransformationCard({ - transform, - showIllustrations, - onClick, data = [], + fullWidth = false, + onClick, + showIllustrations, showPluginState = true, showTags = true, + transform, }: TransformationCardProps) { const theme = useTheme2(); - const styles = useStyles2(getTransformationCardStyles); + const styles = useStyles2(getCardStyles, fullWidth); // Check to see if the transform is applicable to the given data let applicabilityScore = TransformationApplicabilityLevels.Applicable; @@ -47,7 +50,7 @@ export function TransformationCard({ } } - const cardClasses = !isApplicable && data.length > 0 ? cx(styles.newCard, styles.cardDisabled) : styles.newCard; + const cardClasses = cx(styles.baseCard, { [styles.cardDisabled]: !isApplicable }); const imageUrl = theme.isDark ? transform.imageDark : transform.imageLight; const description = standardTransformersRegistry.getIfExists(transform.id)?.description; @@ -58,15 +61,11 @@ export function TransformationCard({ onClick={() => onClick(transform.id)} noMargin > - -
- {transform.name} - {showPluginState && ( - - - - )} -
+ + + {transform.name} + {showPluginState && } + {showTags && transform.tags && transform.tags.size > 0 && (
{Array.from(transform.tags).map((tag) => ( @@ -75,74 +74,13 @@ export function TransformationCard({
)}
- - {description} - {showIllustrations && imageUrl && ( - - {transform.name} - - )} + + {description || ''} + {showIllustrations && imageUrl && {transform.name}} {!isApplicable && applicabilityDescription !== null && ( - + )}
); } - -function getTransformationCardStyles(theme: GrafanaTheme2) { - return { - heading: css({ - fontWeight: 400, - '> button': { - width: '100%', - display: 'flex', - flexDirection: 'column', - alignItems: 'flex-start', - gap: theme.spacing(1), - }, - }), - titleRow: css({ - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - flexWrap: 'nowrap', - width: '100%', - }), - description: css({ - fontSize: theme.typography.bodySmall.fontSize, - display: 'flex', - flexDirection: 'column', - justifyContent: 'space-between', - }), - image: css({ - display: 'block', - maxWidth: '100%', - marginTop: theme.spacing(2), - }), - cardDisabled: css({ - backgroundColor: theme.colors.action.disabledBackground, - img: { - filter: 'grayscale(100%)', - opacity: 0.33, - }, - }), - cardApplicableInfo: css({ - position: 'absolute', - bottom: theme.spacing(1), - right: theme.spacing(1), - }), - newCard: css({ - gridTemplateRows: 'min-content 0 1fr 0', - marginBottom: 0, - }), - pluginStateInfoWrapper: css({ - marginLeft: theme.spacing(0.5), - }), - tagsWrapper: css({ - display: 'flex', - flexWrap: 'wrap', - gap: theme.spacing(0.5), - }), - }; -} diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationPickerNg.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationPickerNg.tsx index fb0be6864f5..e27e554fada 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/TransformationPickerNg.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationPickerNg.tsx @@ -165,11 +165,12 @@ function TransformationsGrid({ showIllustrations, transformations, onClick, data {transformations.map((transform) => ( ))} diff --git a/public/app/features/dashboard/components/TransformationsEditor/getCardStyles.ts b/public/app/features/dashboard/components/TransformationsEditor/getCardStyles.ts new file mode 100644 index 00000000000..b3989282ee2 --- /dev/null +++ b/public/app/features/dashboard/components/TransformationsEditor/getCardStyles.ts @@ -0,0 +1,34 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; + +export const getCardStyles = (theme: GrafanaTheme2, fullWidth?: boolean) => ({ + baseCard: css({ + maxWidth: fullWidth ? 'none' : '200px', + width: fullWidth ? '100%' : 'auto', + marginBottom: 0, + }), + image: css({ + display: 'block', + maxWidth: '100%', + marginTop: theme.spacing(2), + }), + cardDisabled: css({ + backgroundColor: theme.colors.action.disabledBackground, + img: { + filter: 'grayscale(100%)', + opacity: 0.33, + }, + }), + applicableInfoButton: css({ + position: 'absolute', + bottom: theme.spacing(1), + right: theme.spacing(1), + }), + tagsWrapper: css({ + display: 'flex', + flexWrap: 'wrap', + gap: theme.spacing(0.5), + marginTop: theme.spacing(0.5), + }), +}); From 014d4758c68a091de9ce4e553c46933e4d057163 Mon Sep 17 00:00:00 2001 From: Kristina Demeshchik Date: Tue, 30 Dec 2025 14:27:38 -0500 Subject: [PATCH 111/229] Dashboards: Prevent row selection when clicking canvas add actions (#115580) * event propogation issues * Action items width * prevent pointer up event --- .../grafana-ui/src/components/PanelChrome/PanelChrome.tsx | 8 +++++--- .../scene/layouts-shared/CanvasGridAddActions.tsx | 7 +++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx index 8eace0b38b8..f969bbcf3f0 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx @@ -248,15 +248,17 @@ export function PanelChrome({ const onContentPointerDown = React.useCallback( (evt: React.PointerEvent) => { - // Ignore clicks inside buttons, links, canvas and svg elments + // When selected, ignore clicks inside buttons, links, canvas and svg elments // This does prevent a clicks inside a graphs from selecting panel as there is normal div above the canvas element that intercepts the click - if (evt.target instanceof Element && evt.target.closest('button,a,canvas,svg')) { + if (isSelected && evt.target instanceof Element && evt.target.closest('button,a,canvas,svg')) { + // Stop propagation otherwise row config editor will get selected + evt.stopPropagation(); return; } onSelect?.(evt); }, - [onSelect] + [isSelected, onSelect] ); const headerContent = ( diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/CanvasGridAddActions.tsx b/public/app/features/dashboard-scene/scene/layouts-shared/CanvasGridAddActions.tsx index dd5c4ac20b6..9f75b5b7be4 100644 --- a/public/app/features/dashboard-scene/scene/layouts-shared/CanvasGridAddActions.tsx +++ b/public/app/features/dashboard-scene/scene/layouts-shared/CanvasGridAddActions.tsx @@ -59,7 +59,11 @@ export function CanvasGridAddActions({ layoutManager }: Props) { }, [layoutManager]); return ( -
+
evt.stopPropagation()} + onPointerDown={(evt) => evt.stopPropagation()} + > - )} - - - + + + + {showBackButton && ( + + )} + + + + {listMode === VisualizationSelectPaneTab.Suggestions && ( + + )} + {listMode === VisualizationSelectPaneTab.Visualizations && ( - - )} + )} +
@@ -155,7 +162,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ gap: theme.spacing(2), }), searchField: css({ - marginTop: theme.spacing(0.5), // input glow with the boundary without this + margin: theme.spacing(0.5, 0, 1, 0), // input glow with the boundary without this }), tabs: css({ width: '100%', diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx index d1763ad835f..924b5f3b6bf 100644 --- a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx +++ b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx @@ -9,8 +9,10 @@ import { PanelPluginMeta, PanelPluginVisualizationSuggestion, } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; +import { VizPanel } from '@grafana/scenes'; import { Alert, Button, Icon, Spinner, Text, useStyles2 } from '@grafana/ui'; import { UNCONFIGURED_PANEL_PLUGIN_ID } from 'app/features/dashboard-scene/scene/UnconfiguredPanel'; @@ -23,25 +25,47 @@ import { VisualizationSuggestionCard } from './VisualizationSuggestionCard'; import { VizTypeChangeDetails } from './types'; export interface Props { - onChange: (options: VizTypeChangeDetails) => void; + onChange: (options: VizTypeChangeDetails, panel?: VizPanel) => void; + editPreview?: VizPanel; data?: PanelData; panel?: PanelModel; + searchQuery?: string; } -const useSuggestions = (data: PanelData | undefined) => { +const useSuggestions = (data: PanelData | undefined, searchQuery: string | undefined) => { const [hasFetched, setHasFetched] = useState(false); const { value, loading, error, retry } = useAsyncRetry(async () => { await new Promise((resolve) => setTimeout(resolve, hasFetched ? 75 : 0)); setHasFetched(true); return await getAllSuggestions(data); }, [hasFetched, data]); - return { value, loading, error, retry }; + + const filteredValue = useMemo(() => { + if (!value || !searchQuery) { + return value; + } + + const lowerCaseQuery = searchQuery.toLowerCase(); + const filteredSuggestions = value.suggestions.filter( + (suggestion) => + suggestion.name.toLowerCase().includes(lowerCaseQuery) || + suggestion.pluginId.toLowerCase().includes(lowerCaseQuery) || + suggestion.description?.toLowerCase().includes(lowerCaseQuery) + ); + + return { + ...value, + suggestions: filteredSuggestions, + }; + }, [value, searchQuery]); + + return { value: filteredValue, loading, error, retry }; }; -export function VisualizationSuggestions({ onChange, data, panel }: Props) { +export function VisualizationSuggestions({ onChange, editPreview, data, panel, searchQuery }: Props) { const styles = useStyles2(getStyles); - const { value: result, loading, error, retry } = useSuggestions(data); + const { value: result, loading, error, retry } = useSuggestions(data, searchQuery); const suggestions = result?.suggestions; const hasLoadingErrors = result?.hasErrors ?? false; @@ -73,18 +97,21 @@ export function VisualizationSuggestions({ onChange, data, panel }: Props) { const applySuggestion = useCallback( (suggestion: PanelPluginVisualizationSuggestion, isPreview?: boolean) => { - onChange({ - pluginId: suggestion.pluginId, - options: suggestion.options, - fieldConfig: suggestion.fieldConfig, - withModKey: isPreview, - }); + onChange( + { + pluginId: suggestion.pluginId, + options: suggestion.options, + fieldConfig: suggestion.fieldConfig, + withModKey: isPreview, + }, + isPreview ? editPreview : undefined + ); if (isPreview) { setSuggestionHash(suggestion.hash); } }, - [onChange] + [onChange, editPreview] ); useEffect(() => { @@ -185,17 +212,13 @@ export function VisualizationSuggestions({ onChange, data, panel }: Props) { variant="primary" size={'md'} className={styles.applySuggestionButton} + data-testid={selectors.components.VisualizationPreview.confirm(suggestion.name)} aria-label={t( 'panel.visualization-suggestions.apply-suggestion-aria-label', 'Apply {{suggestionName}} visualization', { suggestionName: suggestion.name } )} - onClick={() => - onChange({ - pluginId: suggestion.pluginId, - withModKey: false, - }) - } + onClick={() => applySuggestion(suggestion, false)} > {t('panel.visualization-suggestions.use-this-suggestion', 'Use this suggestion')} From 79ca4e5aec154f9db15912ae50b637ae94fb7c42 Mon Sep 17 00:00:00 2001 From: "alerting-team[bot]" <158350966+alerting-team[bot]@users.noreply.github.com> Date: Wed, 31 Dec 2025 16:04:41 +0000 Subject: [PATCH 121/229] Alerting: Update alerting module to b7821017d69f2e31500fc0e49cd0ba3b85372a1b (#115767) * [create-pull-request] automated change * Fix tests --------- Co-authored-by: alexander-akhmetov <1875873+alexander-akhmetov@users.noreply.github.com> Co-authored-by: Alexander Akhmetov --- apps/advisor/go.mod | 2 +- apps/advisor/go.sum | 4 ++-- apps/alerting/historian/go.mod | 2 +- apps/alerting/historian/go.sum | 4 ++-- apps/iam/go.mod | 2 +- apps/iam/go.sum | 4 ++-- apps/plugins/go.mod | 2 +- apps/plugins/go.sum | 4 ++-- go.mod | 2 +- go.sum | 4 ++-- .../alerting/api_notification_channel_test.go | 4 ++-- .../test-data/alert-notifiers-v1-snapshot.json | 18 ++++++++++++++++++ .../test-data/alert-notifiers-v2-snapshot.json | 18 ++++++++++++++++++ 13 files changed, 53 insertions(+), 17 deletions(-) diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod index 646ceed9a86..84a6ca5f010 100644 --- a/apps/advisor/go.mod +++ b/apps/advisor/go.mod @@ -157,7 +157,7 @@ require ( github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/google/wire v0.7.0 // indirect - github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // indirect + github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // indirect github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect diff --git a/apps/advisor/go.sum b/apps/advisor/go.sum index 750d9f97fc5..873cbf6de62 100644 --- a/apps/advisor/go.sum +++ b/apps/advisor/go.sum @@ -619,8 +619,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= -github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= diff --git a/apps/alerting/historian/go.mod b/apps/alerting/historian/go.mod index fb624d65db3..a79829d45c2 100644 --- a/apps/alerting/historian/go.mod +++ b/apps/alerting/historian/go.mod @@ -4,7 +4,7 @@ go 1.25.5 require ( github.com/go-kit/log v0.2.1 - github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 + github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 github.com/grafana/grafana-app-sdk v0.48.7 github.com/grafana/grafana-app-sdk/logging v0.48.7 diff --git a/apps/alerting/historian/go.sum b/apps/alerting/historian/go.sum index 0835100976a..d45d418dfb8 100644 --- a/apps/alerting/historian/go.sum +++ b/apps/alerting/historian/go.sum @@ -243,8 +243,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= -github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4= github.com/grafana/grafana-app-sdk v0.48.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfUHB32u2ZMo= diff --git a/apps/iam/go.mod b/apps/iam/go.mod index 54689bc54f3..d3f31d6f7a4 100644 --- a/apps/iam/go.mod +++ b/apps/iam/go.mod @@ -223,7 +223,7 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/gorilla/mux v1.8.1 // indirect - github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // indirect + github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // indirect github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect diff --git a/apps/iam/go.sum b/apps/iam/go.sum index 28bf1486774..7e6806e89d0 100644 --- a/apps/iam/go.sum +++ b/apps/iam/go.sum @@ -827,8 +827,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= -github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= diff --git a/apps/plugins/go.mod b/apps/plugins/go.mod index a2657edda7a..678d460910b 100644 --- a/apps/plugins/go.mod +++ b/apps/plugins/go.mod @@ -90,7 +90,7 @@ require ( github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // indirect + github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // indirect github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect diff --git a/apps/plugins/go.sum b/apps/plugins/go.sum index f0c923083af..1c9800a8bab 100644 --- a/apps/plugins/go.sum +++ b/apps/plugins/go.sum @@ -213,8 +213,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= -github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= diff --git a/go.mod b/go.mod index f22d410c51f..becd164c9dd 100644 --- a/go.mod +++ b/go.mod @@ -87,7 +87,7 @@ require ( github.com/googleapis/gax-go/v2 v2.15.0 // @grafana/grafana-backend-group github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // @grafana/grafana-app-platform-squad - github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // @grafana/alerting-backend + github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // @grafana/alerting-backend github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // @grafana/identity-access-team github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // @grafana/identity-access-team github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics diff --git a/go.sum b/go.sum index 069d53dd5e9..ea251101dc8 100644 --- a/go.sum +++ b/go.sum @@ -1622,8 +1622,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= -github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= diff --git a/pkg/tests/api/alerting/api_notification_channel_test.go b/pkg/tests/api/alerting/api_notification_channel_test.go index b35bf6959c5..ea6fa972f97 100644 --- a/pkg/tests/api/alerting/api_notification_channel_test.go +++ b/pkg/tests/api/alerting/api_notification_channel_test.go @@ -2470,7 +2470,7 @@ var expNonEmailNotifications = map[string][]string{ "title_link": "http://localhost:3000/alerting/grafana/UID_SlackAlert1/view?orgId=1", "text": "Integration Test ", "fallback": "Integration Test [FIRING:1] SlackAlert1 (default)", - "footer": "Grafana v", + "footer": "Grafana", "footer_icon": "https://grafana.com/static/assets/img/fav32.png", "color": "#D63232", "ts": %s, @@ -2490,7 +2490,7 @@ var expNonEmailNotifications = map[string][]string{ "title_link": "http://localhost:3000/alerting/grafana/UID_SlackAlert2/view?orgId=1", "text": "**Firing**\n\nValue: A=1\nLabels:\n - alertname = SlackAlert2\n - grafana_folder = default\nAnnotations:\nSource: http://localhost:3000/alerting/grafana/UID_SlackAlert2/view?orgId=1\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=__alert_rule_uid__%%3DUID_SlackAlert2&orgId=1\n", "fallback": "[FIRING:1] SlackAlert2 (default)", - "footer": "Grafana v", + "footer": "Grafana", "footer_icon": "https://grafana.com/static/assets/img/fav32.png", "color": "#D63232", "ts": %s, diff --git a/pkg/tests/api/alerting/test-data/alert-notifiers-v1-snapshot.json b/pkg/tests/api/alerting/test-data/alert-notifiers-v1-snapshot.json index b3dabb7cde2..fe4f2f2f924 100644 --- a/pkg/tests/api/alerting/test-data/alert-notifiers-v1-snapshot.json +++ b/pkg/tests/api/alerting/test-data/alert-notifiers-v1-snapshot.json @@ -2699,6 +2699,24 @@ "secure": false, "dependsOn": "", "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Footer", + "description": "Templated footer of the slack message", + "placeholder": "{{ template \"slack.default.footer\" . }}", + "propertyName": "footer", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null } ] }, diff --git a/pkg/tests/api/alerting/test-data/alert-notifiers-v2-snapshot.json b/pkg/tests/api/alerting/test-data/alert-notifiers-v2-snapshot.json index 50c92e4d069..d3797d9cafa 100644 --- a/pkg/tests/api/alerting/test-data/alert-notifiers-v2-snapshot.json +++ b/pkg/tests/api/alerting/test-data/alert-notifiers-v2-snapshot.json @@ -7017,6 +7017,24 @@ "secure": false, "dependsOn": "", "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Footer", + "description": "Templated footer of the slack message", + "placeholder": "{{ template \"slack.default.footer\" . }}", + "propertyName": "footer", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null } ] }, From 521670981add82ce8368b416fdc590b4f7ef9095 Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Wed, 31 Dec 2025 11:42:09 -0700 Subject: [PATCH 122/229] Zanzana: Add metric for last reconciliation (#115768) --- pkg/server/wire_gen.go | 4 +- .../accesscontrol/dualwrite/reconciler.go | 19 +++- pkg/tests/apis/folder/folder_tree_test.go | 4 + pkg/tests/apis/zanzana_reconcile.go | 87 +++++++++++++++++++ 4 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 pkg/tests/apis/zanzana_reconcile.go diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index b958e5f7ad9..4ae1194ef28 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -847,7 +847,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api if err != nil { return nil, err } - zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService) + zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService, registerer) investigationsAppProvider := investigations.RegisterApp(cfg) appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, investigationsAppProvider, cfg) if err != nil { @@ -1509,7 +1509,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService) + zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService, registerer) investigationsAppProvider := investigations.RegisterApp(cfg) appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, investigationsAppProvider, cfg) if err != nil { diff --git a/pkg/services/accesscontrol/dualwrite/reconciler.go b/pkg/services/accesscontrol/dualwrite/reconciler.go index d66039d44f2..ab27972e86e 100644 --- a/pkg/services/accesscontrol/dualwrite/reconciler.go +++ b/pkg/services/accesscontrol/dualwrite/reconciler.go @@ -6,6 +6,8 @@ import ( "strconv" "time" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" "go.opentelemetry.io/otel" claims "github.com/grafana/authlib/types" @@ -34,12 +36,15 @@ type ZanzanaReconciler struct { store db.DB client zanzana.Client lock *serverlock.ServerLockService + metrics struct { + lastSuccess prometheus.Gauge + } // reconcilers are migrations that tries to reconcile the state of grafana db to zanzana store. // These are run periodically to try to maintain a consistent state. reconcilers []resourceReconciler } -func ProvideZanzanaReconciler(cfg *setting.Cfg, features featuremgmt.FeatureToggles, client zanzana.Client, store db.DB, lock *serverlock.ServerLockService, folderService folder.Service) *ZanzanaReconciler { +func ProvideZanzanaReconciler(cfg *setting.Cfg, features featuremgmt.FeatureToggles, client zanzana.Client, store db.DB, lock *serverlock.ServerLockService, folderService folder.Service, reg prometheus.Registerer) *ZanzanaReconciler { zanzanaReconciler := &ZanzanaReconciler{ cfg: cfg, log: reconcilerLogger, @@ -93,6 +98,13 @@ func ProvideZanzanaReconciler(cfg *setting.Cfg, features featuremgmt.FeatureTogg }, } + if reg != nil { + zanzanaReconciler.metrics.lastSuccess = promauto.With(reg).NewGauge(prometheus.GaugeOpts{ + Name: "grafana_zanzana_reconcile_last_success_timestamp_seconds", + Help: "Unix timestamp (seconds) when the Zanzana reconciler last completed a reconciliation cycle.", + }) + } + if cfg.Anonymous.Enabled { zanzanaReconciler.reconcilers = append(zanzanaReconciler.reconcilers, newResourceReconciler( @@ -165,7 +177,7 @@ func (r *ZanzanaReconciler) hasBasicRolePermissions(ctx context.Context) bool { func (r *ZanzanaReconciler) waitForBasicRolesSeeded(ctx context.Context) { // Best-effort: don't block forever. If we can't observe basic roles, proceed anyway. const ( - maxWait = 30 * time.Second + maxWait = 15 * time.Second interval = 1 * time.Second ) @@ -199,6 +211,9 @@ func (r *ZanzanaReconciler) reconcile(ctx context.Context) { r.log.Warn("Failed to perform reconciliation for resource", "err", err) } } + if r.metrics.lastSuccess != nil { + r.metrics.lastSuccess.SetToCurrentTime() + } r.log.Debug("Finished reconciliation", "elapsed", time.Since(now)) } diff --git a/pkg/tests/apis/folder/folder_tree_test.go b/pkg/tests/apis/folder/folder_tree_test.go index 26e7b5f6884..613d021b236 100644 --- a/pkg/tests/apis/folder/folder_tree_test.go +++ b/pkg/tests/apis/folder/folder_tree_test.go @@ -102,6 +102,8 @@ func runIntegrationFolderTree(t *testing.T, opts testinfra.GrafanaOpts) { helper := apis.NewK8sTestHelper(t, opts) defer helper.Shutdown() + apis.AwaitZanzanaReconcileNext(t, helper) + tests := []struct { Name string Definition FolderDefinition @@ -247,6 +249,8 @@ func (f *FolderDefinition) CreateWithLegacyAPI(t *testing.T, h *apis.K8sTestHelp }) require.NoError(t, err) + apis.AwaitZanzanaReconcileNext(t, h) + var statusCode int result := client.Post().AbsPath("api", "folders"). Body(body). diff --git a/pkg/tests/apis/zanzana_reconcile.go b/pkg/tests/apis/zanzana_reconcile.go new file mode 100644 index 00000000000..f8a5673fed7 --- /dev/null +++ b/pkg/tests/apis/zanzana_reconcile.go @@ -0,0 +1,87 @@ +package apis + +import ( + "bytes" + "context" + "net/http" + "testing" + "time" + + dto "github.com/prometheus/client_model/go" + "github.com/prometheus/common/expfmt" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/services/featuremgmt" +) + +const zanzanaReconcileLastSuccessMetric = "grafana_zanzana_reconcile_last_success_timestamp_seconds" + +// AwaitZanzanaReconcileNext waits for the next Zanzana reconciliation cycle to complete. +// It is a no-op unless the `zanzana` feature toggle is enabled for the running test env. +func AwaitZanzanaReconcileNext(t *testing.T, helper *K8sTestHelper) { + t.Helper() + + enabled := false + if helper != nil { + enabled = helper.GetEnv().FeatureToggles.GetEnabled(context.Background())[featuremgmt.FlagZanzana] + } + if helper == nil || !enabled { + return + } + + prev, ok := getZanzanaReconcileLastSuccessTimestampSeconds(t, helper) + if !ok { + prev = 0 + } + + require.EventuallyWithT(t, func(c *assert.CollectT) { + ts, ok := getZanzanaReconcileLastSuccessTimestampSeconds(t, helper) + assert.True(c, ok, "expected to find %s in /metrics", zanzanaReconcileLastSuccessMetric) + if !ok { + return + } + assert.Greater(c, ts, prev, "expected %s (%v) > %v", zanzanaReconcileLastSuccessMetric, ts, prev) + }, 30*time.Second, 50*time.Millisecond) +} + +func getZanzanaReconcileLastSuccessTimestampSeconds(t *testing.T, helper *K8sTestHelper) (float64, bool) { + t.Helper() + + rsp := DoRequest(helper, RequestParams{ + User: helper.Org1.Admin, + Path: "/metrics", + Accept: "text/plain", + }, &struct{}{}) + if rsp.Response == nil || rsp.Response.StatusCode != http.StatusOK { + return 0, false + } + + parser := expfmt.NewTextParser(model.UTF8Validation) + metrics, err := parser.TextToMetricFamilies(bytes.NewReader(rsp.Body)) + if err != nil { + return 0, false + } + + metric := metrics[zanzanaReconcileLastSuccessMetric] + if metric == nil || len(metric.Metric) == 0 { + return 0, false + } + + m := metric.Metric[0] + switch metric.GetType() { + case dto.MetricType_GAUGE: + if m.Gauge == nil { + return 0, false + } + return m.Gauge.GetValue(), true + case dto.MetricType_UNTYPED: + if m.Untyped == nil { + return 0, false + } + return m.Untyped.GetValue(), true + default: + return 0, false + } +} From 33a1c60433652108c6aac18611eebac2d01195af Mon Sep 17 00:00:00 2001 From: Kristina Demeshchik Date: Fri, 2 Jan 2026 02:15:40 -0500 Subject: [PATCH 123/229] Dashboard: Add lazy loading for repeated panels (#115047) Co-authored-by: Haris Rozajac Co-authored-by: Ivan Ortega --- .../dashboard-scene/scene/DashboardScene.tsx | 3 +- .../scene/SoloPanelContext.tsx | 18 +++++-- .../layout-auto-grid/AutoGridItemRenderer.tsx | 7 ++- .../DashboardGridItemRenderer.tsx | 50 +++++++++++++------ .../DefaultGridLayoutManager.tsx | 11 ++-- .../scene/layout-rows/RowsLayoutManager.tsx | 3 +- 6 files changed, 62 insertions(+), 30 deletions(-) diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 7ddd7c4e779..91adc3660a8 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -90,7 +90,6 @@ import { DashboardGridItem } from './layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; import { addNewRowTo } from './layouts-shared/addNew'; import { clearClipboard } from './layouts-shared/paste'; -import { getIsLazy } from './layouts-shared/utils'; import { DashboardLayoutManager } from './types/DashboardLayoutManager'; import { LayoutParent } from './types/LayoutParent'; @@ -199,7 +198,7 @@ export class DashboardScene extends SceneObjectBase impleme meta: {}, editable: true, $timeRange: state.$timeRange ?? new SceneTimeRange({}), - body: state.body ?? DefaultGridLayoutManager.fromVizPanels([], getIsLazy(state.preload)), + body: state.body ?? DefaultGridLayoutManager.fromVizPanels([]), links: state.links ?? [], ...state, editPane: new DashboardEditPane(), diff --git a/public/app/features/dashboard-scene/scene/SoloPanelContext.tsx b/public/app/features/dashboard-scene/scene/SoloPanelContext.tsx index 2186d9b4863..b1eca307731 100644 --- a/public/app/features/dashboard-scene/scene/SoloPanelContext.tsx +++ b/public/app/features/dashboard-scene/scene/SoloPanelContext.tsx @@ -1,7 +1,7 @@ import React, { useContext, useEffect, useState } from 'react'; import { Trans } from '@grafana/i18n'; -import { VizPanel } from '@grafana/scenes'; +import { LazyLoader, VizPanel } from '@grafana/scenes'; import { Box, Spinner } from '@grafana/ui'; import { DashboardScene } from './DashboardScene'; @@ -51,11 +51,23 @@ export function useSoloPanelContext() { return useContext(SoloPanelContext); } -export function renderMatchingSoloPanels(soloPanelContext: SoloPanelContextValue, panels: VizPanel[]) { +export function renderMatchingSoloPanels( + soloPanelContext: SoloPanelContextValue, + panels: VizPanel[], + isLazy?: boolean +) { const matches: React.ReactNode[] = []; for (const panel of panels) { if (soloPanelContext.matches(panel)) { - matches.push(); + if (isLazy) { + matches.push( + + + + ); + } else { + matches.push(); + } } } diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx index 15b7e82ae36..6ead7a35d22 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx @@ -8,6 +8,7 @@ import { useStyles2 } from '@grafana/ui'; import { ConditionalRenderingGroup } from '../../conditional-rendering/group/ConditionalRenderingGroup'; import { useIsConditionallyHidden } from '../../conditional-rendering/hooks/useIsConditionallyHidden'; import { useDashboardState } from '../../utils/utils'; +import { SoloPanelContextValueWithSearchStringFilter } from '../PanelSearchLayout'; import { renderMatchingSoloPanels, useSoloPanelContext } from '../SoloPanelContext'; import { getIsLazy } from '../layouts-shared/utils'; @@ -89,7 +90,11 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps; +} + +function PanelWrapper({ panel, isLazy, containerRef }: PanelWrapperProps) { + if (isLazy) { + return ( + + + + ); + } + return ( +
+ +
+ ); +} + export function DashboardGridItemRenderer({ model }: SceneComponentProps) { const { repeatedPanels = [], itemHeight, variableName, body } = model.useState(); const soloPanelContext = useSoloPanelContext(); + const { preload } = useDashboardState(model); + const isLazy = useMemo(() => getIsLazy(preload), [preload]); const layoutStyle = useLayoutStyle( model.getRepeatDirection(), model.getChildCount(), @@ -20,26 +46,22 @@ export function DashboardGridItemRenderer({ model }: SceneComponentProps - -
- ); + return ; } return (
-
- -
+ {repeatedPanels.map((panel) => ( -
- -
+ ))}
); diff --git a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx index e299272de78..68288297e42 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx @@ -47,7 +47,6 @@ import { AutoGridItem } from '../layout-auto-grid/AutoGridItem'; import { CanvasGridAddActions } from '../layouts-shared/CanvasGridAddActions'; import { clearClipboard, getDashboardGridItemFromClipboard } from '../layouts-shared/paste'; import { dashboardCanvasAddButtonHoverStyles } from '../layouts-shared/styles'; -import { getIsLazy } from '../layouts-shared/utils'; import { DashboardLayoutGrid } from '../types/DashboardLayoutGrid'; import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { LayoutRegistryItem } from '../types/LayoutRegistryItem'; @@ -565,11 +564,10 @@ export class DefaultGridLayoutManager public static createFromLayout(currentLayout: DashboardLayoutManager): DefaultGridLayoutManager { const panels = currentLayout.getVizPanels(); - const isLazy = getIsLazy(getDashboardSceneFor(currentLayout).state.preload)!; - return DefaultGridLayoutManager.fromVizPanels(panels, isLazy); + return DefaultGridLayoutManager.fromVizPanels(panels); } - public static fromVizPanels(panels: VizPanel[] = [], isLazy?: boolean | undefined): DefaultGridLayoutManager { + public static fromVizPanels(panels: VizPanel[] = []): DefaultGridLayoutManager { const children: DashboardGridItem[] = []; const panelHeight = 10; const panelWidth = GRID_COLUMN_COUNT / 3; @@ -607,7 +605,6 @@ export class DefaultGridLayoutManager children: children, isDraggable: true, isResizable: true, - isLazy, }), }); } @@ -615,8 +612,7 @@ export class DefaultGridLayoutManager public static fromGridItems( gridItems: SceneGridItemLike[], isDraggable?: boolean, - isResizable?: boolean, - isLazy?: boolean | undefined + isResizable?: boolean ): DefaultGridLayoutManager { const children = gridItems.reduce((acc, gridItem) => { gridItem.clearParent(); @@ -630,7 +626,6 @@ export class DefaultGridLayoutManager children, isDraggable, isResizable, - isLazy, }), }); } diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx index 48f11357e24..b7459463958 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx @@ -358,8 +358,7 @@ export class RowsLayoutManager extends SceneObjectBase i layout: DefaultGridLayoutManager.fromGridItems( rowConfig.children, rowConfig.isDraggable ?? layout.state.grid.state.isDraggable, - rowConfig.isResizable ?? layout.state.grid.state.isResizable, - layout.state.grid.state.isLazy + rowConfig.isResizable ?? layout.state.grid.state.isResizable ), }) ); From dc4c106e91b68caa876d08944efbad730ee3734b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20Sencer=20=C3=96zcan?= <32759850+mustafasencer@users.noreply.github.com> Date: Fri, 2 Jan 2026 13:51:51 +0100 Subject: [PATCH 124/229] fix: use memory index if index file already open (#115720) * feat: add lock structure into bleve index files * fix: another approach * fix: new check * fix: build in memory if index file already open * fix: update workspace * fix: add test * refactor: update func signature * fix: address comments * fix: make const --- go.mod | 2 +- pkg/storage/unified/search/bleve.go | 73 +++++++++++++++++------- pkg/storage/unified/search/bleve_test.go | 73 ++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 22 deletions(-) diff --git a/go.mod b/go.mod index becd164c9dd..8768e51f86a 100644 --- a/go.mod +++ b/go.mod @@ -181,6 +181,7 @@ require ( github.com/xlab/treeprint v1.2.0 // @grafana/observability-traces-and-profiling github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // @grafana/grafana-operator-experience-squad github.com/yudai/gojsondiff v1.0.0 // @grafana/grafana-backend-group + go.etcd.io/bbolt v1.4.2 // @grafana/grafana-search-and-storage go.opentelemetry.io/collector/pdata v1.44.0 // @grafana/grafana-backend-group go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 // @grafana/plugins-platform-backend go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 // @grafana/grafana-operator-experience-squad @@ -603,7 +604,6 @@ require ( github.com/yuin/gopher-lua v1.1.1 // indirect github.com/zclconf/go-cty v1.16.3 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect - go.etcd.io/bbolt v1.4.2 // indirect go.etcd.io/etcd/api/v3 v3.6.6 // indirect go.etcd.io/etcd/client/pkg/v3 v3.6.6 // indirect go.etcd.io/etcd/client/v3 v3.6.6 // indirect diff --git a/pkg/storage/unified/search/bleve.go b/pkg/storage/unified/search/bleve.go index eb9fa4df3bd..d6ff00a81c0 100644 --- a/pkg/storage/unified/search/bleve.go +++ b/pkg/storage/unified/search/bleve.go @@ -25,6 +25,7 @@ import ( bleveSearch "github.com/blevesearch/bleve/v2/search/searcher" index "github.com/blevesearch/bleve_index_api" "github.com/prometheus/client_golang/prometheus" + bolterrors "go.etcd.io/bbolt/errors" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.uber.org/atomic" @@ -44,6 +45,7 @@ import ( const ( indexStorageMemory = "memory" indexStorageFile = "file" + boltTimeout = "500ms" ) // Keys used to store internal data in index. @@ -415,14 +417,25 @@ func (b *bleveBackend) BuildIndex( // This happens on startup, or when memory-based index has expired. (We don't expire file-based indexes) // If we do have an unexpired cached index already, we always build a new index from scratch. if cachedIndex == nil && !rebuild { - index, fileIndexName, indexRV = b.findPreviousFileBasedIndex(resourceDir) + result := b.findPreviousFileBasedIndex(resourceDir) + if result != nil && result.IsOpen { + // Index file exists but is opened by another process, fallback to memory. + // Keep the name so we can skip cleanup of that directory. + newIndexType = indexStorageMemory + fileIndexName = result.Name + } else if result != nil && result.Index != nil { + // Found and opened existing index successfully + index = result.Index + fileIndexName = result.Name + indexRV = result.RV + } } - if index != nil { + if newIndexType == indexStorageFile && index != nil { build = false logWithDetails.Debug("Existing index found on filesystem", "indexRV", indexRV, "directory", filepath.Join(resourceDir, fileIndexName)) defer closeIndexOnExit(index, "") // Close index, but don't delete directory. - } else { + } else if newIndexType == indexStorageFile { // Building index from scratch. Index name has a time component in it to be unique, but if // we happen to create non-unique name, we bump the time and try again. @@ -449,7 +462,9 @@ func (b *bleveBackend) BuildIndex( logWithDetails.Info("Building index using filesystem", "directory", indexDir) defer closeIndexOnExit(index, indexDir) // Close index, and delete new index directory. } - } else { + } + + if newIndexType == indexStorageMemory { index, err = newBleveIndex("", mapper, time.Now(), b.opts.BuildVersion) if err != nil { return nil, fmt.Errorf("error creating new in-memory bleve index: %w", err) @@ -552,30 +567,30 @@ func cleanFileSegment(input string) string { return input } -// cleanOldIndexes deletes all subdirectories inside dir, skipping directory with "skipName". +// cleanOldIndexes deletes all subdirectories inside resourceDir, skipping directory with "skipName". // "skipName" can be empty. -func (b *bleveBackend) cleanOldIndexes(dir string, skipName string) { - files, err := os.ReadDir(dir) +func (b *bleveBackend) cleanOldIndexes(resourceDir string, skipName string) { + entries, err := os.ReadDir(resourceDir) if err != nil { if os.IsNotExist(err) { return } - b.log.Warn("error cleaning folders from", "directory", dir, "error", err) + b.log.Warn("error cleaning folders from", "directory", resourceDir, "error", err) return } - for _, file := range files { - if file.IsDir() && file.Name() != skipName { - fpath := filepath.Join(dir, file.Name()) - if !isPathWithinRoot(fpath, b.opts.Root) { - b.log.Warn("Skipping cleanup of directory", "directory", fpath) + for _, ent := range entries { + if ent.IsDir() && ent.Name() != skipName { + indexDir := filepath.Join(resourceDir, ent.Name()) + if !isPathWithinRoot(indexDir, b.opts.Root) { + b.log.Warn("Skipping cleanup of directory", "directory", indexDir) continue } - err = os.RemoveAll(fpath) + err = os.RemoveAll(indexDir) if err != nil { - b.log.Error("Unable to remove old index folder", "directory", fpath, "error", err) + b.log.Error("Unable to remove old index folder", "directory", indexDir, "error", err) } else { - b.log.Info("Removed old index folder", "directory", fpath) + b.log.Info("Removed old index folder", "directory", indexDir) } } } @@ -622,10 +637,17 @@ func formatIndexName(now time.Time) string { return now.Format("20060102-150405") } -func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) (bleve.Index, string, int64) { +type fileIndex struct { + Index bleve.Index + Name string + RV int64 + IsOpen bool +} + +func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) *fileIndex { entries, err := os.ReadDir(resourceDir) if err != nil { - return nil, "", 0 + return nil } for _, ent := range entries { @@ -635,8 +657,13 @@ func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) (bleve.Ind indexName := ent.Name() indexDir := filepath.Join(resourceDir, indexName) - idx, err := bleve.Open(indexDir) + + idx, err := bleve.OpenUsing(indexDir, map[string]interface{}{"bolt_timeout": boltTimeout}) if err != nil { + if errors.Is(err, bolterrors.ErrTimeout) { + b.log.Debug("Index is opened by another process (timeout), skipping", "indexDir", indexDir) + return &fileIndex{Name: indexName, IsOpen: true} + } b.log.Debug("error opening index", "indexDir", indexDir, "err", err) continue } @@ -648,10 +675,14 @@ func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) (bleve.Ind continue } - return idx, indexName, indexRV + return &fileIndex{ + Index: idx, + Name: indexName, + RV: indexRV, + } } - return nil, "", 0 + return nil } // Stop closes all indexes and stops background tasks. diff --git a/pkg/storage/unified/search/bleve_test.go b/pkg/storage/unified/search/bleve_test.go index a23f261cfc5..c879440e7b6 100644 --- a/pkg/storage/unified/search/bleve_test.go +++ b/pkg/storage/unified/search/bleve_test.go @@ -1583,3 +1583,76 @@ func docCount(t *testing.T, idx resource.ResourceIndex) int { require.NoError(t, err) return int(cnt) } + +func TestBleveBackendFallsBackToMemory(t *testing.T) { + ns := resource.NamespacedResource{ + Namespace: "test", + Group: "group", + Resource: "resource", + } + + tmpDir := t.TempDir() + + // First, create a file-based index with one backend and keep it open + backend1, reg1 := setupBleveBackend(t, withRootDir(tmpDir)) + index1, err := backend1.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false) + require.NoError(t, err) + require.NotNil(t, index1) + + // Verify first index is file-based + bleveIdx1, ok := index1.(*bleveIndex) + require.True(t, ok) + require.Equal(t, indexStorageFile, bleveIdx1.indexStorage) + checkOpenIndexes(t, reg1, 0, 1) + + // Now create a second backend using the same directory + // This simulates another instance trying to open the same index + backend2, reg2 := setupBleveBackend(t, withRootDir(tmpDir)) + + // BuildIndex should detect the file is locked and fallback to memory + index2, err := backend2.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false) + require.NoError(t, err) + require.NotNil(t, index2) + + // Verify second index fell back to in-memory despite size being above file threshold + bleveIdx2, ok := index2.(*bleveIndex) + require.True(t, ok) + require.Equal(t, indexStorageMemory, bleveIdx2.indexStorage) + + // Verify metrics show 1 memory index and 0 file indexes for backend2 + checkOpenIndexes(t, reg2, 1, 0) + + // Verify the in-memory index works correctly + require.Equal(t, 10, docCount(t, index2)) + + // Clean up: close first backend to release the file lock + backend1.Stop() +} + +func TestBleveSkipCleanOldIndexesOnMemoryFallback(t *testing.T) { + ns := resource.NamespacedResource{ + Namespace: "test", + Group: "group", + Resource: "resource", + } + + tmpDir := t.TempDir() + + backend1, _ := setupBleveBackend(t, withRootDir(tmpDir)) + _, err := backend1.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false) + require.NoError(t, err) + + // Now create a second backend using the same directory + // This simulates another instance trying to open the same index + backend2, _ := setupBleveBackend(t, withRootDir(tmpDir)) + + // BuildIndex should detect the file is locked and fallback to memory + _, err = backend2.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false) + require.NoError(t, err) + + // Verify that the index directory still exists (i.e., cleanOldIndexes was skipped) + verifyDirEntriesCount(t, backend2.getResourceDir(ns), 1) + + // Clean up: close first backend to release the file lock + backend1.Stop() +} From 105b4076297047890fead0b6c4fc9bfdae860383 Mon Sep 17 00:00:00 2001 From: Will Browne Date: Fri, 2 Jan 2026 15:52:10 +0000 Subject: [PATCH 125/229] Plugins: Sync validator plugin.json schema copy edits back to source of truth (#115790) sync validator copy edits back to source of truth --- docs/sources/developers/plugins/plugin.schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sources/developers/plugins/plugin.schema.json b/docs/sources/developers/plugins/plugin.schema.json index 1898cd46b94..cae948ce4f3 100644 --- a/docs/sources/developers/plugins/plugin.schema.json +++ b/docs/sources/developers/plugins/plugin.schema.json @@ -369,7 +369,7 @@ "description": "For data source plugins. Proxy routes used for plugin authentication and adding headers to HTTP requests made by the plugin. For more information, refer to [Authentication for data source plugins](https://grafana.com/developers/plugin-tools/how-to-guides/data-source-plugins/add-authentication-for-data-source-plugins).", "items": { "type": "object", - "description": "", + "description": "For data source plugins. Proxy routes used for plugin authentication and adding headers to HTTP requests made by the plugin. For more information, refer to [Authentication for data source plugins](https://grafana.com/developers/plugin-tools/how-to-guides/data-source-plugins/add-authentication-for-data-source-plugins).", "additionalProperties": false, "properties": { "path": { From 967ba3acaf2ee71c211fee66b44840cbe4583119 Mon Sep 17 00:00:00 2001 From: Kristina Demeshchik Date: Fri, 2 Jan 2026 13:12:04 -0500 Subject: [PATCH 126/229] Dashboard: Fix dashboardUID in conversion logs to use actual dashboard UID (#115797) udpate loggers --- apps/dashboard/pkg/migration/conversion/metrics.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/dashboard/pkg/migration/conversion/metrics.go b/apps/dashboard/pkg/migration/conversion/metrics.go index 5a60aa848de..9cbdec193e8 100644 --- a/apps/dashboard/pkg/migration/conversion/metrics.go +++ b/apps/dashboard/pkg/migration/conversion/metrics.go @@ -85,20 +85,20 @@ func withConversionMetrics(sourceVersionAPI, targetVersionAPI string, conversion // Only track schema versions for v0/v1 dashboards (v2+ info is redundant with API version) switch source := a.(type) { case *dashv0.Dashboard: - dashboardUID = string(source.UID) + dashboardUID = source.Name if source.Spec.Object != nil { sourceSchemaVersion = schemaversion.GetSchemaVersion(source.Spec.Object) } case *dashv1.Dashboard: - dashboardUID = string(source.UID) + dashboardUID = source.Name if source.Spec.Object != nil { sourceSchemaVersion = schemaversion.GetSchemaVersion(source.Spec.Object) } case *dashv2alpha1.Dashboard: - dashboardUID = string(source.UID) + dashboardUID = source.Name // Don't track schema version for v2+ (redundant with API version) case *dashv2beta1.Dashboard: - dashboardUID = string(source.UID) + dashboardUID = source.Name // Don't track schema version for v2+ (redundant with API version) } From eb2a390425611773b892b5b04f9103268bd7aab5 Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Mon, 5 Jan 2026 00:51:23 -0700 Subject: [PATCH 127/229] Unistore: Prevent deadlock on startup errors (#115799) --- pkg/storage/unified/sql/service.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/storage/unified/sql/service.go b/pkg/storage/unified/sql/service.go index 75b3e80fcb0..06275c8754c 100644 --- a/pkg/storage/unified/sql/service.go +++ b/pkg/storage/unified/sql/service.go @@ -115,6 +115,7 @@ func ProvideUnifiedStorageGrpcService( cfg: cfg, features: features, stopCh: make(chan struct{}), + stoppedCh: make(chan error, 1), authenticator: authn, tracing: tracer, db: db, From 3b3e87ff898157d8572614e3339dfcbdc1fb4e5f Mon Sep 17 00:00:00 2001 From: Gareth Date: Mon, 5 Jan 2026 16:35:19 +0700 Subject: [PATCH 128/229] OpenTSDB: Migrate frontend requests to data source backend (#115221) * OpenTSDB: Migrate metadata queries to data source backend * OpenTSDB: Migrate annotations to the data source backend * return errors for failed unmarshal * remove trailing / from metadata requests * remove console logs --- pkg/tsdb/opentsdb/callresource.go | 386 ++++++++++++++++++ pkg/tsdb/opentsdb/opentsdb.go | 3 + pkg/tsdb/opentsdb/types.go | 13 +- pkg/tsdb/opentsdb/utils.go | 12 +- .../plugins/datasource/opentsdb/datasource.ts | 109 +++-- 5 files changed, 493 insertions(+), 30 deletions(-) diff --git a/pkg/tsdb/opentsdb/callresource.go b/pkg/tsdb/opentsdb/callresource.go index be0f81b9c80..74ed9b53188 100644 --- a/pkg/tsdb/opentsdb/callresource.go +++ b/pkg/tsdb/opentsdb/callresource.go @@ -1,10 +1,13 @@ package opentsdb import ( + "encoding/json" "fmt" "net/http" "net/url" "path" + "sort" + "strings" "github.com/grafana/grafana-plugin-sdk-go/backend" ) @@ -65,3 +68,386 @@ func (s *Service) HandleSuggestQuery(rw http.ResponseWriter, req *http.Request) return } } + +func (s *Service) HandleAggregatorsQuery(rw http.ResponseWriter, req *http.Request) { + logger := logger.FromContext(req.Context()) + + dsInfo, err := s.getDSInfo(req.Context(), backend.PluginConfigFromContext(req.Context())) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to get datasource info: %v", err), http.StatusInternalServerError) + return + } + + u, err := url.Parse(dsInfo.URL) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to parse datasource URL: %v", err), http.StatusInternalServerError) + return + } + + u.Path = path.Join(u.Path, "api/aggregators") + httpReq, err := http.NewRequestWithContext(req.Context(), http.MethodGet, u.String(), nil) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to create request: %v", err), http.StatusInternalServerError) + return + } + + res, err := dsInfo.HTTPClient.Do(httpReq) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to execute request: %v", err), http.StatusInternalServerError) + return + } + + defer func() { + if err := res.Body.Close(); err != nil { + logger.Error("Failed to close response body", "error", err) + } + }() + + responseBody, err := DecodeResponseBody(res, logger) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to decode response: %v", err), http.StatusInternalServerError) + return + } + + var aggregators []string + if err := json.Unmarshal(responseBody, &aggregators); err != nil { + http.Error(rw, fmt.Sprintf("failed to unmarshal aggregators response: %v", err), http.StatusInternalServerError) + return + } + + sort.Strings(aggregators) + sortedResponse, err := json.Marshal(aggregators) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to marshal response: %v", err), http.StatusInternalServerError) + return + } + + for name, values := range res.Header { + if name == "Content-Encoding" || name == "Content-Length" { + continue + } + for _, value := range values { + rw.Header().Add(name, value) + } + } + + rw.WriteHeader(res.StatusCode) + if _, err := rw.Write(sortedResponse); err != nil { + logger.Error("Failed to write response", "error", err) + return + } +} + +func (s *Service) HandleFiltersQuery(rw http.ResponseWriter, req *http.Request) { + logger := logger.FromContext(req.Context()) + + dsInfo, err := s.getDSInfo(req.Context(), backend.PluginConfigFromContext(req.Context())) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to get datasource info: %v", err), http.StatusInternalServerError) + return + } + + u, err := url.Parse(dsInfo.URL) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to parse datasource URL: %v", err), http.StatusInternalServerError) + return + } + + u.Path = path.Join(u.Path, "/api/config/filters") + httpReq, err := http.NewRequestWithContext(req.Context(), http.MethodGet, u.String(), nil) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to create request: %v", err), http.StatusInternalServerError) + return + } + + res, err := dsInfo.HTTPClient.Do(httpReq) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to execute request: %v", err), http.StatusInternalServerError) + return + } + + defer func() { + if err := res.Body.Close(); err != nil { + logger.Error("Failed to close response body", "error", err) + } + }() + + responseBody, err := DecodeResponseBody(res, logger) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to decode response: %v", err), http.StatusInternalServerError) + return + } + + var filters map[string]json.RawMessage + if err := json.Unmarshal(responseBody, &filters); err != nil { + http.Error(rw, fmt.Sprintf("failed to unmarshal filters response: %v", err), http.StatusInternalServerError) + return + } + + keys := make([]string, 0, len(filters)) + for key := range filters { + keys = append(keys, key) + } + + sort.Strings(keys) + sortedResponse, err := json.Marshal(keys) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to marshal response: %v", err), http.StatusInternalServerError) + return + } + + for name, values := range res.Header { + if name == "Content-Encoding" || name == "Content-Length" { + continue + } + for _, value := range values { + rw.Header().Add(name, value) + } + } + + rw.WriteHeader(res.StatusCode) + if _, err := rw.Write(sortedResponse); err != nil { + logger.Error("Failed to write response", "error", err) + return + } +} + +func (s *Service) HandleLookupQuery(rw http.ResponseWriter, req *http.Request) { + queryParams := req.URL.Query() + typeParam := queryParams.Get("type") + if typeParam == "" { + http.Error(rw, "missing 'type' parameter", http.StatusBadRequest) + return + } + + switch typeParam { + case "key": + s.HandleKeyLookup(rw, req, queryParams) + case "keyvalue": + s.HandleKeyValueLookup(rw, req, queryParams) + default: + http.Error(rw, fmt.Sprintf("unsupported type: %s", typeParam), http.StatusBadRequest) + return + } +} + +func (s *Service) HandleKeyLookup(rw http.ResponseWriter, req *http.Request, queryParams url.Values) { + logger := logger.FromContext(req.Context()) + + dsInfo, err := s.getDSInfo(req.Context(), backend.PluginConfigFromContext(req.Context())) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to get datasource info: %v", err), http.StatusInternalServerError) + return + } + + metric := queryParams.Get("metric") + if metric == "" { + http.Error(rw, "missing 'metric' parameter", http.StatusBadRequest) + return + } + + u, err := url.Parse(dsInfo.URL) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to parse datasource URL: %v", err), http.StatusInternalServerError) + return + } + + u.Path = path.Join(u.Path, "api/search/lookup") + lookupQueryParams := u.Query() + lookupQueryParams.Set("m", metric) + lookupQueryParams.Set("limit", "1000") + u.RawQuery = lookupQueryParams.Encode() + + httpReq, err := http.NewRequestWithContext(req.Context(), http.MethodGet, u.String(), nil) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to create request: %v", err), http.StatusInternalServerError) + return + } + + res, err := dsInfo.HTTPClient.Do(httpReq) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to execute request: %v", err), http.StatusInternalServerError) + return + } + + defer func() { + if err := res.Body.Close(); err != nil { + logger.Error("Failed to close response body", "error", err) + } + }() + + responseBody, err := DecodeResponseBody(res, logger) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to decode response: %v", err), http.StatusInternalServerError) + return + } + + var lookupResponse struct { + Results []struct { + Tags map[string]string `json:"tags"` + } `json:"results"` + } + + if err := json.Unmarshal(responseBody, &lookupResponse); err != nil { + http.Error(rw, fmt.Sprintf("failed to unmarshal lookup response: %v", err), http.StatusInternalServerError) + return + } + + tagKeysMap := make(map[string]bool) + for _, result := range lookupResponse.Results { + for tagKey := range result.Tags { + tagKeysMap[tagKey] = true + } + } + + tagKeys := make([]string, 0, len(tagKeysMap)) + for tagKey := range tagKeysMap { + tagKeys = append(tagKeys, tagKey) + } + + sort.Strings(tagKeys) + sortedResponse, err := json.Marshal(tagKeys) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to marshal response: %v", err), http.StatusInternalServerError) + return + } + + for name, values := range res.Header { + if name == "Content-Encoding" || name == "Content-Length" { + continue + } + for _, value := range values { + rw.Header().Add(name, value) + } + } + + rw.Header().Set("Content-Type", "application/json") + rw.WriteHeader(res.StatusCode) + if _, err := rw.Write(sortedResponse); err != nil { + logger.Error("Failed to write response", "error", err) + return + } +} + +func (s *Service) HandleKeyValueLookup(rw http.ResponseWriter, req *http.Request, queryParams url.Values) { + logger := logger.FromContext(req.Context()) + + dsInfo, err := s.getDSInfo(req.Context(), backend.PluginConfigFromContext(req.Context())) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to get datasource info: %v", err), http.StatusInternalServerError) + return + } + + metric := queryParams.Get("metric") + if metric == "" { + http.Error(rw, "missing 'metric' parameter", http.StatusBadRequest) + return + } + + keys := queryParams.Get("keys") + if keys == "" { + http.Error(rw, "missing 'keys' parameter", http.StatusBadRequest) + return + } + + keysArray := strings.Split(keys, ",") + for i := range keysArray { + keysArray[i] = strings.TrimSpace(keysArray[i]) + } + + if len(keysArray) == 0 { + http.Error(rw, "keys parameter cannot be empty", http.StatusBadRequest) + return + } + + key := keysArray[0] + keysQuery := key + "=*" + + if len(keysArray) > 1 { + keysQuery += "," + strings.Join(keysArray[1:], ",") + } + + m := metric + "{" + keysQuery + "}" + + u, err := url.Parse(dsInfo.URL) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to parse datasource URL: %v", err), http.StatusInternalServerError) + return + } + + u.Path = path.Join(u.Path, "api/search/lookup") + lookupQueryParams := u.Query() + lookupQueryParams.Set("m", m) + lookupQueryParams.Set("limit", fmt.Sprintf("%d", dsInfo.LookupLimit)) + u.RawQuery = lookupQueryParams.Encode() + + httpReq, err := http.NewRequestWithContext(req.Context(), http.MethodGet, u.String(), nil) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to create request: %v", err), http.StatusInternalServerError) + return + } + + res, err := dsInfo.HTTPClient.Do(httpReq) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to execute request: %v", err), http.StatusInternalServerError) + return + } + + defer func() { + if err := res.Body.Close(); err != nil { + logger.Error("Failed to close response body", "error", err) + } + }() + + responseBody, err := DecodeResponseBody(res, logger) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to decode response: %v", err), http.StatusInternalServerError) + return + } + + var lookupResponse struct { + Results []struct { + Tags map[string]string `json:"tags"` + } `json:"results"` + } + + if err := json.Unmarshal(responseBody, &lookupResponse); err != nil { + http.Error(rw, fmt.Sprintf("failed to unmarshal lookup response: %v", err), http.StatusInternalServerError) + return + } + + tagValuesMap := make(map[string]bool) + for _, result := range lookupResponse.Results { + if tagValue, exists := result.Tags[key]; exists { + tagValuesMap[tagValue] = true + } + } + + tagValues := make([]string, 0, len(tagValuesMap)) + for tagValue := range tagValuesMap { + tagValues = append(tagValues, tagValue) + } + + sort.Strings(tagValues) + sortedResponse, err := json.Marshal(tagValues) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to marshal response: %v", err), http.StatusInternalServerError) + return + } + + for name, values := range res.Header { + if name == "Content-Encoding" || name == "Content-Length" { + continue + } + for _, value := range values { + rw.Header().Add(name, value) + } + } + + rw.Header().Set("Content-Type", "application/json") + rw.WriteHeader(res.StatusCode) + if _, err := rw.Write(sortedResponse); err != nil { + logger.Error("Failed to write response", "error", err) + return + } +} diff --git a/pkg/tsdb/opentsdb/opentsdb.go b/pkg/tsdb/opentsdb/opentsdb.go index a694445e1cd..533fadccb75 100644 --- a/pkg/tsdb/opentsdb/opentsdb.go +++ b/pkg/tsdb/opentsdb/opentsdb.go @@ -152,6 +152,9 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { mux := http.NewServeMux() mux.HandleFunc("/api/suggest", s.HandleSuggestQuery) + mux.HandleFunc("/api/aggregators", s.HandleAggregatorsQuery) + mux.HandleFunc("/api/config/filters", s.HandleFiltersQuery) + mux.HandleFunc("/api/search/lookup", s.HandleLookupQuery) handler := httpadapter.New(mux) return handler.CallResource(ctx, req, sender) diff --git a/pkg/tsdb/opentsdb/types.go b/pkg/tsdb/opentsdb/types.go index 89aed49baa8..0a01239ce65 100644 --- a/pkg/tsdb/opentsdb/types.go +++ b/pkg/tsdb/opentsdb/types.go @@ -7,9 +7,16 @@ type OpenTsdbQuery struct { } type OpenTsdbCommon struct { - Metric string `json:"metric"` - Tags map[string]string `json:"tags"` - AggregateTags []string `json:"aggregateTags"` + Metric string `json:"metric"` + Tags map[string]string `json:"tags"` + AggregateTags []string `json:"aggregateTags"` + Annotations []OpenTsdbAnnotation `json:"annotations,omitempty"` + GlobalAnnotations []OpenTsdbAnnotation `json:"globalAnnotations,omitempty"` +} + +type OpenTsdbAnnotation struct { + Description string `json:"description"` + StartTime float64 `json:"startTime"` } type OpenTsdbResponse struct { diff --git a/pkg/tsdb/opentsdb/utils.go b/pkg/tsdb/opentsdb/utils.go index ddfa8122fce..df3ea67ae25 100644 --- a/pkg/tsdb/opentsdb/utils.go +++ b/pkg/tsdb/opentsdb/utils.go @@ -198,11 +198,21 @@ func CreateDataFrame(val OpenTsdbCommon, length int, refID string) *data.Frame { sort.Strings(tagKeys) tagKeys = append(tagKeys, val.AggregateTags...) + custom := map[string]any{ + "tagKeys": tagKeys, + } + if len(val.Annotations) > 0 { + custom["annotations"] = val.Annotations + } + if len(val.GlobalAnnotations) > 0 { + custom["globalAnnotations"] = val.GlobalAnnotations + } + frame := data.NewFrameOfFieldTypes(val.Metric, length, data.FieldTypeTime, data.FieldTypeFloat64) frame.Meta = &data.FrameMeta{ Type: data.FrameTypeTimeSeriesMulti, TypeVersion: data.FrameTypeVersion{0, 1}, - Custom: map[string]any{"tagKeys": tagKeys}, + Custom: custom, } frame.RefID = refID timeField := frame.Fields[0] diff --git a/public/app/plugins/datasource/opentsdb/datasource.ts b/public/app/plugins/datasource/opentsdb/datasource.ts index 24356eefbac..da3473be8ad 100644 --- a/public/app/plugins/datasource/opentsdb/datasource.ts +++ b/public/app/plugins/datasource/opentsdb/datasource.ts @@ -77,8 +77,28 @@ export default class OpenTsDatasource extends DataSourceWithBackend): Observable { + if (options.targets.some((target: OpenTsdbQuery) => target.fromAnnotations)) { + const streams: Array> = []; + + for (const annotation of options.targets) { + if (annotation.target) { + streams.push( + new Observable((subscriber) => { + this.annotationEvent(options, annotation) + .then((events) => subscriber.next({ data: [toDataFrame(events)] })) + .catch((ex) => { + return subscriber.next({ data: [toDataFrame([])] }); + }) + .finally(() => subscriber.complete()); + }) + ); + } + } + + return merge(...streams); + } + if (config.featureToggles.opentsdbBackendMigration) { const hasValidTargets = options.targets.some((target) => target.metric && !target.hide); if (!hasValidTargets) { @@ -93,31 +113,6 @@ export default class OpenTsDatasource extends DataSourceWithBackend target.fromAnnotations)) { - const streams: Array> = []; - - for (const annotation of options.targets) { - if (annotation.target) { - streams.push( - new Observable((subscriber) => { - this.annotationEvent(options, annotation) - .then((events) => subscriber.next({ data: [toDataFrame(events)] })) - .catch((ex) => { - // grafana fetch throws the error so for annotation consistency among datasources - // we return an empty array which displays as 'no events found' - // in the annnotation editor - return subscriber.next({ data: [toDataFrame([])] }); - }) - .finally(() => subscriber.complete()); - }) - ); - } - } - - return merge(...streams); - } - const start = this.convertToTSDBTime(options.range.raw.from, false, options.timezone); const end = this.convertToTSDBTime(options.range.raw.to, true, options.timezone); const qs: any[] = []; @@ -181,6 +176,50 @@ export default class OpenTsDatasource extends DataSourceWithBackend { + if (config.featureToggles.opentsdbBackendMigration) { + const query: OpenTsdbQuery = { + refId: annotation.refId ?? 'Anno', + metric: annotation.target, + aggregator: 'sum', + fromAnnotations: true, + isGlobal: annotation.isGlobal, + disableDownsampling: true, + }; + + const queryRequest: DataQueryRequest = { + ...options, + targets: [query], + }; + + return lastValueFrom( + super.query(queryRequest).pipe( + map((response) => { + const eventList: AnnotationEvent[] = []; + + for (const frame of response.data) { + const annotationObject = annotation.isGlobal + ? frame.meta?.custom?.globalAnnotations + : frame.meta?.custom?.annotations; + + if (annotationObject && isArray(annotationObject)) { + annotationObject.forEach((ann) => { + const event: AnnotationEvent = { + text: ann.description, + time: Math.floor(ann.startTime) * 1000, + annotation: annotation, + }; + + eventList.push(event); + }); + } + } + + return eventList; + }) + ) + ); + } + const start = this.convertToTSDBTime(options.range.raw.from, false, options.timezone); const end = this.convertToTSDBTime(options.range.raw.to, true, options.timezone); const qs = []; @@ -306,6 +345,10 @@ export default class OpenTsDatasource extends DataSourceWithBackend { return key.trim(); }); @@ -337,6 +380,10 @@ export default class OpenTsDatasource extends DataSourceWithBackend { result = result.data.results; @@ -450,6 +497,11 @@ export default class OpenTsDatasource extends DataSourceWithBackend { @@ -468,6 +520,11 @@ export default class OpenTsDatasource extends DataSourceWithBackend { From 1a0bc39ec3907a6b86e82d12b3cd30940d67a2dd Mon Sep 17 00:00:00 2001 From: Will Browne Date: Mon, 5 Jan 2026 09:42:47 +0000 Subject: [PATCH 129/229] Plugins: Remove some pkg/infra/* dependencies from pkg/plugins (#115795) * tackle some /pkg/infra/* packages * run make update-workspace * add owner for slugify dep --- apps/advisor/go.mod | 1 + apps/advisor/go.sum | 2 ++ apps/iam/go.mod | 1 + apps/iam/go.sum | 2 ++ apps/plugins/go.mod | 2 +- apps/plugins/go.sum | 4 +-- go.mod | 2 ++ go.sum | 2 ++ .../backendplugin/coreplugin/registry.go | 6 ++-- .../backendplugin/coreplugin/registry_test.go | 4 +-- .../backendplugin/grpcplugin/grpc_plugin.go | 9 ----- .../manager/pipeline/bootstrap/bootstrap.go | 2 +- .../manager/pipeline/bootstrap/steps.go | 3 +- .../manager/pipeline/discovery/discovery.go | 2 +- .../pipeline/initialization/initialization.go | 2 +- .../pipeline/termination/termination.go | 2 +- .../manager/pipeline/validation/validation.go | 2 +- .../manager/sources/source_local_disk.go | 12 +++---- pkg/plugins/tracing/tracing.go | 35 +++++++++++++++++++ pkg/server/wire_gen.go | 8 ++--- 20 files changed, 69 insertions(+), 34 deletions(-) create mode 100644 pkg/plugins/tracing/tracing.go diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod index 84a6ca5f010..314726c5ecb 100644 --- a/apps/advisor/go.mod +++ b/apps/advisor/go.mod @@ -54,6 +54,7 @@ require ( github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0 // indirect github.com/BurntSushi/toml v1.5.0 // indirect + github.com/Machiel/slugify v1.0.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect diff --git a/apps/advisor/go.sum b/apps/advisor/go.sum index 873cbf6de62..112228d6ed8 100644 --- a/apps/advisor/go.sum +++ b/apps/advisor/go.sum @@ -115,6 +115,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo= github.com/IBM/pgxpoolprometheus v1.1.2 h1:sHJwxoL5Lw4R79Zt+H4Uj1zZ4iqXJLdk7XDE7TPs97U= github.com/IBM/pgxpoolprometheus v1.1.2/go.mod h1:+vWzISN6S9ssgurhUNmm6AlXL9XLah3TdWJktquKTR8= +github.com/Machiel/slugify v1.0.1 h1:EfWSlRWstMadsgzmiV7d0yVd2IFlagWH68Q+DcYCm4E= +github.com/Machiel/slugify v1.0.1/go.mod h1:fTFGn5uWEynW4CUMG7sWkYXOf1UgDxyTM3DbR6Qfg3k= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= diff --git a/apps/iam/go.mod b/apps/iam/go.mod index d3f31d6f7a4..aed406c5434 100644 --- a/apps/iam/go.mod +++ b/apps/iam/go.mod @@ -89,6 +89,7 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect github.com/HdrHistogram/hdrhistogram-go v1.1.2 // indirect github.com/IBM/pgxpoolprometheus v1.1.2 // indirect + github.com/Machiel/slugify v1.0.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect diff --git a/apps/iam/go.sum b/apps/iam/go.sum index 7e6806e89d0..35997e0d1ec 100644 --- a/apps/iam/go.sum +++ b/apps/iam/go.sum @@ -167,6 +167,8 @@ github.com/HdrHistogram/hdrhistogram-go v1.1.2 h1:5IcZpTvzydCQeHzK4Ef/D5rrSqwxob github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= github.com/IBM/pgxpoolprometheus v1.1.2 h1:sHJwxoL5Lw4R79Zt+H4Uj1zZ4iqXJLdk7XDE7TPs97U= github.com/IBM/pgxpoolprometheus v1.1.2/go.mod h1:+vWzISN6S9ssgurhUNmm6AlXL9XLah3TdWJktquKTR8= +github.com/Machiel/slugify v1.0.1 h1:EfWSlRWstMadsgzmiV7d0yVd2IFlagWH68Q+DcYCm4E= +github.com/Machiel/slugify v1.0.1/go.mod h1:fTFGn5uWEynW4CUMG7sWkYXOf1UgDxyTM3DbR6Qfg3k= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= diff --git a/apps/plugins/go.mod b/apps/plugins/go.mod index 678d460910b..9a3e3776efb 100644 --- a/apps/plugins/go.mod +++ b/apps/plugins/go.mod @@ -23,6 +23,7 @@ require ( require ( cel.dev/expr v0.25.1 // indirect + github.com/Machiel/slugify v1.0.1 // indirect github.com/NYTimes/gziphandler v1.1.1 // indirect github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect @@ -191,7 +192,6 @@ require ( go.opentelemetry.io/contrib/propagators/jaeger v1.38.0 // indirect go.opentelemetry.io/contrib/samplers/jaegerremote v0.32.0 // indirect go.opentelemetry.io/otel v1.39.0 // indirect - go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 // indirect diff --git a/apps/plugins/go.sum b/apps/plugins/go.sum index 1c9800a8bab..3a7e9849fad 100644 --- a/apps/plugins/go.sum +++ b/apps/plugins/go.sum @@ -7,6 +7,8 @@ filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4 github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/Machiel/slugify v1.0.1 h1:EfWSlRWstMadsgzmiV7d0yVd2IFlagWH68Q+DcYCm4E= +github.com/Machiel/slugify v1.0.1/go.mod h1:fTFGn5uWEynW4CUMG7sWkYXOf1UgDxyTM3DbR6Qfg3k= github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= @@ -541,8 +543,6 @@ go.opentelemetry.io/contrib/samplers/jaegerremote v0.32.0/go.mod h1:B9Oka5QVD0bn go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM07rqoCWzXu7Sqy+4= -go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM= diff --git a/go.mod b/go.mod index 8768e51f86a..83d82e3af5d 100644 --- a/go.mod +++ b/go.mod @@ -660,6 +660,8 @@ require ( require github.com/grafana/tempo v1.5.1-0.20250529124718-87c2dc380cec // @grafana/observability-traces-and-profiling +require github.com/Machiel/slugify v1.0.1 // @grafana/plugins-platform-backend + require ( github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect github.com/IBM/pgxpoolprometheus v1.1.2 // indirect diff --git a/go.sum b/go.sum index ea251101dc8..2b3b2cb4e3f 100644 --- a/go.sum +++ b/go.sum @@ -738,6 +738,8 @@ github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXY github.com/IBM/pgxpoolprometheus v1.1.2 h1:sHJwxoL5Lw4R79Zt+H4Uj1zZ4iqXJLdk7XDE7TPs97U= github.com/IBM/pgxpoolprometheus v1.1.2/go.mod h1:+vWzISN6S9ssgurhUNmm6AlXL9XLah3TdWJktquKTR8= github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk= +github.com/Machiel/slugify v1.0.1 h1:EfWSlRWstMadsgzmiV7d0yVd2IFlagWH68Q+DcYCm4E= +github.com/Machiel/slugify v1.0.1/go.mod h1:fTFGn5uWEynW4CUMG7sWkYXOf1UgDxyTM3DbR6Qfg3k= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= diff --git a/pkg/plugins/backendplugin/coreplugin/registry.go b/pkg/plugins/backendplugin/coreplugin/registry.go index 1e610b1ef1c..fb17fd279b8 100644 --- a/pkg/plugins/backendplugin/coreplugin/registry.go +++ b/pkg/plugins/backendplugin/coreplugin/registry.go @@ -10,8 +10,8 @@ import ( sdktracing "github.com/grafana/grafana-plugin-sdk-go/backend/tracing" "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "go.opentelemetry.io/otel/trace" - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/plugins/log" @@ -94,7 +94,7 @@ func NewRegistry(store map[string]backendplugin.PluginFactoryFunc) *Registry { } } -func ProvideCoreRegistry(tracer tracing.Tracer, am *azuremonitor.Service, cw *cloudwatch.Service, cm *cloudmonitoring.Service, +func ProvideCoreRegistry(tracer trace.Tracer, am *azuremonitor.Service, cw *cloudwatch.Service, cm *cloudmonitoring.Service, es *elasticsearch.Service, grap *graphite.Service, idb *influxdb.Service, lk *loki.Service, otsdb *opentsdb.Service, pr *prometheus.Service, t *tempo.Service, td *testdatasource.Service, pg *postgres.Service, my *mysql.Service, ms *mssql.Service, graf *grafanads.Service, pyroscope *pyroscope.Service, parca *parca.Service, zipkin *zipkin.Service, jaeger *jaeger.Service) *Registry { @@ -204,7 +204,7 @@ var ErrCorePluginNotFound = errors.New("core plugin not found") // NewPlugin factory for creating and initializing a single core plugin. // Note: cfg only needed for mssql connection pooling defaults. -func NewPlugin(pluginID string, cfg *setting.Cfg, httpClientProvider *httpclient.Provider, tracer tracing.Tracer, features featuremgmt.FeatureToggles) (*plugins.Plugin, error) { +func NewPlugin(pluginID string, cfg *setting.Cfg, httpClientProvider *httpclient.Provider, tracer trace.Tracer, features featuremgmt.FeatureToggles) (*plugins.Plugin, error) { jsonData := plugins.JSONData{ ID: pluginID, AliasIDs: []string{}, diff --git a/pkg/plugins/backendplugin/coreplugin/registry_test.go b/pkg/plugins/backendplugin/coreplugin/registry_test.go index 41a1ca7f7ec..76f531a25b7 100644 --- a/pkg/plugins/backendplugin/coreplugin/registry_test.go +++ b/pkg/plugins/backendplugin/coreplugin/registry_test.go @@ -4,8 +4,8 @@ import ( "testing" "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins/log" + "github.com/grafana/grafana/pkg/plugins/tracing" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/require" @@ -46,7 +46,7 @@ func TestNewPlugin(t *testing.T) { tc.ExpectedID = tc.ID } - p, err := NewPlugin(tc.ID, setting.NewCfg(), httpclient.NewProvider(), tracing.InitializeTracerForTest(), featuremgmt.WithFeatures()) + p, err := NewPlugin(tc.ID, setting.NewCfg(), httpclient.NewProvider(), tracing.NoopTracer(), featuremgmt.WithFeatures()) if tc.ExpectedNotFoundErr { require.ErrorIs(t, err, ErrCorePluginNotFound) require.Nil(t, p) diff --git a/pkg/plugins/backendplugin/grpcplugin/grpc_plugin.go b/pkg/plugins/backendplugin/grpcplugin/grpc_plugin.go index d1bcb5640a2..f8ffd6d6d71 100644 --- a/pkg/plugins/backendplugin/grpcplugin/grpc_plugin.go +++ b/pkg/plugins/backendplugin/grpcplugin/grpc_plugin.go @@ -9,7 +9,6 @@ import ( "github.com/hashicorp/go-plugin" "go.opentelemetry.io/otel/trace" - "github.com/grafana/grafana/pkg/infra/process" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/plugins/log" @@ -90,14 +89,6 @@ func (p *grpcPlugin) Start(_ context.Context) error { return errors.New("no compatible plugin implementation found") } - elevated, err := process.IsRunningWithElevatedPrivileges() - if err != nil { - p.logger.Debug("Error checking plugin process execution privilege", "error", err) - } - if elevated { - p.logger.Warn("Plugin process is running with elevated privileges. This is not recommended") - } - p.state = pluginStateStartSuccess return nil } diff --git a/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go b/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go index e6845322516..f20c1ff1ead 100644 --- a/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go +++ b/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go @@ -6,12 +6,12 @@ import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace" - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/pluginassets" + "github.com/grafana/grafana/pkg/plugins/tracing" "github.com/grafana/grafana/pkg/semconv" ) diff --git a/pkg/plugins/manager/pipeline/bootstrap/steps.go b/pkg/plugins/manager/pipeline/bootstrap/steps.go index 7608ba2c4fa..5c365ebb47c 100644 --- a/pkg/plugins/manager/pipeline/bootstrap/steps.go +++ b/pkg/plugins/manager/pipeline/bootstrap/steps.go @@ -5,7 +5,8 @@ import ( "path" "slices" - "github.com/grafana/grafana/pkg/infra/slugify" + "github.com/Machiel/slugify" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" diff --git a/pkg/plugins/manager/pipeline/discovery/discovery.go b/pkg/plugins/manager/pipeline/discovery/discovery.go index e5bdc50dd62..08a74b1cce0 100644 --- a/pkg/plugins/manager/pipeline/discovery/discovery.go +++ b/pkg/plugins/manager/pipeline/discovery/discovery.go @@ -7,10 +7,10 @@ import ( "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" + "github.com/grafana/grafana/pkg/plugins/tracing" ) // Discoverer is responsible for the Discovery stage of the plugin loader pipeline. diff --git a/pkg/plugins/manager/pipeline/initialization/initialization.go b/pkg/plugins/manager/pipeline/initialization/initialization.go index 4319f4811a7..6a697fc7009 100644 --- a/pkg/plugins/manager/pipeline/initialization/initialization.go +++ b/pkg/plugins/manager/pipeline/initialization/initialization.go @@ -6,10 +6,10 @@ import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace" - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" + "github.com/grafana/grafana/pkg/plugins/tracing" "github.com/grafana/grafana/pkg/semconv" ) diff --git a/pkg/plugins/manager/pipeline/termination/termination.go b/pkg/plugins/manager/pipeline/termination/termination.go index fdb28396bbf..f27ec531bc7 100644 --- a/pkg/plugins/manager/pipeline/termination/termination.go +++ b/pkg/plugins/manager/pipeline/termination/termination.go @@ -6,10 +6,10 @@ import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace" - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" + "github.com/grafana/grafana/pkg/plugins/tracing" "github.com/grafana/grafana/pkg/semconv" ) diff --git a/pkg/plugins/manager/pipeline/validation/validation.go b/pkg/plugins/manager/pipeline/validation/validation.go index 36db1f25163..465ed0ce089 100644 --- a/pkg/plugins/manager/pipeline/validation/validation.go +++ b/pkg/plugins/manager/pipeline/validation/validation.go @@ -6,10 +6,10 @@ import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace" - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" + "github.com/grafana/grafana/pkg/plugins/tracing" "github.com/grafana/grafana/pkg/semconv" ) diff --git a/pkg/plugins/manager/sources/source_local_disk.go b/pkg/plugins/manager/sources/source_local_disk.go index 0ec55afbe0b..22830b69734 100644 --- a/pkg/plugins/manager/sources/source_local_disk.go +++ b/pkg/plugins/manager/sources/source_local_disk.go @@ -10,7 +10,6 @@ import ( "slices" "strings" - "github.com/grafana/grafana/pkg/infra/fs" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" @@ -79,15 +78,14 @@ func (s *LocalSource) Discover(_ context.Context) ([]*plugins.FoundBundle, error pluginJSONPaths := make([]string, 0, len(s.paths)) for _, path := range s.paths { - exists, err := fs.Exists(path) - if err != nil { + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + s.log.Warn("Skipping finding plugins as directory does not exist", "path", path) + continue + } s.log.Warn("Skipping finding plugins as an error occurred", "path", path, "error", err) continue } - if !exists { - s.log.Warn("Skipping finding plugins as directory does not exist", "path", path) - continue - } paths, err := s.getAbsPluginJSONPaths(path) if err != nil { diff --git a/pkg/plugins/tracing/tracing.go b/pkg/plugins/tracing/tracing.go new file mode 100644 index 00000000000..f039b10914b --- /dev/null +++ b/pkg/plugins/tracing/tracing.go @@ -0,0 +1,35 @@ +package tracing + +import ( + "context" + "net/http" + + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + "go.opentelemetry.io/otel/trace/noop" +) + +// Tracer defines the service used to create new spans. +type Tracer interface { + trace.Tracer + + // Inject adds identifying information for the span to the + // headers defined in [http.Header] map (this mutates http.Header). + Inject(context.Context, http.Header, trace.Span) +} + +// Error sets the status to error and record the error as an exception in the provided span. +// This is a simplified version that works directly with OpenTelemetry spans. +func Error(span trace.Span, err error) error { + if err == nil { + return nil + } + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return err +} + +// NoopTracer returns a no-op tracer that can be used when tracing is not available. +func NoopTracer() trace.Tracer { + return noop.NewTracerProvider().Tracer("") +} diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 4ae1194ef28..6569066fcdf 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -390,13 +390,13 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api return nil, err } validate := pipeline.ProvideValidationStage(pluginManagementCfg, validation, angularinspectorService) + tracer := otelTracer() ossDataSourceRequestURLValidator := validations.ProvideURLValidator() httpclientProvider := httpclientprovider.New(cfg, ossDataSourceRequestURLValidator, tracingService) azuremonitorService := azuremonitor.ProvideService(httpclientProvider) cloudwatchService := cloudwatch.ProvideService() cloudmonitoringService := cloudmonitoring.ProvideService(httpclientProvider) elasticsearchService := elasticsearch.ProvideService(httpclientProvider) - tracer := otelTracer() graphiteService := graphite.ProvideService(httpclientProvider, tracer) influxdbService := influxdb.ProvideService(httpclientProvider) lokiService := loki.ProvideService(httpclientProvider, tracer) @@ -556,7 +556,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api parcaService := parca.ProvideService(httpclientProvider) zipkinService := zipkin.ProvideService(httpclientProvider) jaegerService := jaeger.ProvideService(httpclientProvider) - corepluginRegistry := coreplugin.ProvideCoreRegistry(tracingService, azuremonitorService, cloudwatchService, cloudmonitoringService, elasticsearchService, graphiteService, influxdbService, lokiService, opentsdbService, prometheusService, tempoService, testdatasourceService, postgresService, mysqlService, mssqlService, grafanadsService, pyroscopeService, parcaService, zipkinService, jaegerService) + corepluginRegistry := coreplugin.ProvideCoreRegistry(tracer, azuremonitorService, cloudwatchService, cloudmonitoringService, elasticsearchService, graphiteService, influxdbService, lokiService, opentsdbService, prometheusService, tempoService, testdatasourceService, postgresService, mysqlService, mssqlService, grafanadsService, pyroscopeService, parcaService, zipkinService, jaegerService) providerService := provider2.ProvideService(corepluginRegistry) processService := process.ProvideService() retrieverService := retriever.ProvideService(sqlStore, apikeyService, kvStore, userService, orgService) @@ -1050,13 +1050,13 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac return nil, err } validate := pipeline.ProvideValidationStage(pluginManagementCfg, validation, angularinspectorService) + tracer := otelTracer() ossDataSourceRequestURLValidator := validations.ProvideURLValidator() httpclientProvider := httpclientprovider.New(cfg, ossDataSourceRequestURLValidator, tracingService) azuremonitorService := azuremonitor.ProvideService(httpclientProvider) cloudwatchService := cloudwatch.ProvideService() cloudmonitoringService := cloudmonitoring.ProvideService(httpclientProvider) elasticsearchService := elasticsearch.ProvideService(httpclientProvider) - tracer := otelTracer() graphiteService := graphite.ProvideService(httpclientProvider, tracer) influxdbService := influxdb.ProvideService(httpclientProvider) lokiService := loki.ProvideService(httpclientProvider, tracer) @@ -1216,7 +1216,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac parcaService := parca.ProvideService(httpclientProvider) zipkinService := zipkin.ProvideService(httpclientProvider) jaegerService := jaeger.ProvideService(httpclientProvider) - corepluginRegistry := coreplugin.ProvideCoreRegistry(tracingService, azuremonitorService, cloudwatchService, cloudmonitoringService, elasticsearchService, graphiteService, influxdbService, lokiService, opentsdbService, prometheusService, tempoService, testdatasourceService, postgresService, mysqlService, mssqlService, grafanadsService, pyroscopeService, parcaService, zipkinService, jaegerService) + corepluginRegistry := coreplugin.ProvideCoreRegistry(tracer, azuremonitorService, cloudwatchService, cloudmonitoringService, elasticsearchService, graphiteService, influxdbService, lokiService, opentsdbService, prometheusService, tempoService, testdatasourceService, postgresService, mysqlService, mssqlService, grafanadsService, pyroscopeService, parcaService, zipkinService, jaegerService) providerService := provider2.ProvideService(corepluginRegistry) processService := process.ProvideService() retrieverService := retriever.ProvideService(sqlStore, apikeyService, kvStore, userService, orgService) From 76a6db818e6b036da6127fa88a8c43d333698b19 Mon Sep 17 00:00:00 2001 From: Mariell Hoversholm Date: Mon, 5 Jan 2026 11:07:23 +0100 Subject: [PATCH 130/229] Frontend: Remove bootstrap (#115813) --- public/vendor/bootstrap/bootstrap.js | 1512 -------------------------- 1 file changed, 1512 deletions(-) delete mode 100644 public/vendor/bootstrap/bootstrap.js diff --git a/public/vendor/bootstrap/bootstrap.js b/public/vendor/bootstrap/bootstrap.js deleted file mode 100644 index 8730550092a..00000000000 --- a/public/vendor/bootstrap/bootstrap.js +++ /dev/null @@ -1,1512 +0,0 @@ -/* =================================================== - * bootstrap-transition.js v2.3.2 - * http://getbootstrap.com/2.3.2/javascript.html#transitions - * =================================================== - * Copyright 2013 Twitter, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * ========================================================== */ - - -!function($) { - - "use strict"; // jshint ;_; - - - /* CSS TRANSITION SUPPORT (http://www.modernizr.com/) - * ======================================================= */ - - $(function() { - - $.support.transition = (function() { - - var transitionEnd = (function() { - - var el = document.createElement('bootstrap') - , transEndEventNames = { - 'WebkitTransition': 'webkitTransitionEnd' - , 'MozTransition': 'transitionend' - , 'OTransition': 'oTransitionEnd otransitionend' - , 'transition': 'transitionend' - } - , name - - for (name in transEndEventNames) { - if (el.style[name] !== undefined) { - return transEndEventNames[name] - } - } - - }()) - - return transitionEnd && { - end: transitionEnd - } - - })() - - }) - -}(window.jQuery);/* ========================================================== - * bootstrap-alert.js v2.3.2 - * http://getbootstrap.com/2.3.2/javascript.html#alerts - * ========================================================== - * Copyright 2013 Twitter, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * ========================================================== */ - - -!function($) { - - "use strict"; // jshint ;_; - - /* ============================================================ - * bootstrap-dropdown.js v2.3.2 - * http://getbootstrap.com/2.3.2/javascript.html#dropdowns - * ============================================================ - * Copyright 2013 Twitter, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * ============================================================ */ - - - /* DROPDOWN CLASS DEFINITION - * ========================= */ - - var toggle = '[data-toggle=dropdown]' - , Dropdown = function(element) { - var $el = $(element).on('click.dropdown.data-api', this.toggle) - $('html').on('click.dropdown.data-api', function() { - $el.parent().removeClass('open') - }) - } - - Dropdown.prototype = { - - constructor: Dropdown - - , toggle: function(e) { - var $this = $(this) - , $parent - , isActive - - if ($this.is('.disabled, :disabled')) return - - $parent = getParent($this) - - isActive = $parent.hasClass('open') - - clearMenus() - - if (!isActive) { - if ('ontouchstart' in document.documentElement) { - // if mobile we we use a backdrop because click events don't delegate - $('