Compare commits
75 Commits
python-run
...
git-branch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf4ddeafae | ||
|
|
c2e95e90b0 | ||
|
|
0189385620 | ||
|
|
b8c88a8ae3 | ||
|
|
69250b5fc2 | ||
|
|
ad1d277fea | ||
|
|
f996b7e55c | ||
|
|
0de02d7919 | ||
|
|
046625674d | ||
|
|
21f6b03346 | ||
|
|
3c2ec25dcd | ||
|
|
323871270c | ||
|
|
51f1e0dc6e | ||
|
|
39cf18ed43 | ||
|
|
cb46d2e45e | ||
|
|
28c612ac42 | ||
|
|
f7554f9685 | ||
|
|
f0aaa2f0a8 | ||
|
|
34f8815c2f | ||
|
|
59fed921ff | ||
|
|
70a701b719 | ||
|
|
eef3b79fc5 | ||
|
|
fa87409575 | ||
|
|
2780d34324 | ||
|
|
d6dfe042fd | ||
|
|
858753bd51 | ||
|
|
41160173d6 | ||
|
|
c30c2a1f5c | ||
|
|
0e107be7cd | ||
|
|
74d8625540 | ||
|
|
b3afb44973 | ||
|
|
5099353234 | ||
|
|
fe55e1188f | ||
|
|
f43f13b9cc | ||
|
|
e142fb9569 | ||
|
|
b6039acdf6 | ||
|
|
f254383ed7 | ||
|
|
b591d3e35a | ||
|
|
8857f87a4b | ||
|
|
ca278eb44a | ||
|
|
36694ffb3b | ||
|
|
c0773fee3a | ||
|
|
4e9c823053 | ||
|
|
792f67160f | ||
|
|
53268fb3ab | ||
|
|
3abd0211d8 | ||
|
|
a08364900e | ||
|
|
c7c6a0653a | ||
|
|
c809523ca8 | ||
|
|
6a6bb16429 | ||
|
|
149526487c | ||
|
|
63b3951f82 | ||
|
|
72e50a9fe3 | ||
|
|
e4467116b8 | ||
|
|
a317588160 | ||
|
|
1c56fd147c | ||
|
|
2f349488c3 | ||
|
|
65af399b7c | ||
|
|
fcdbfd299b | ||
|
|
335b6dca9e | ||
|
|
2771eda614 | ||
|
|
228722f230 | ||
|
|
ba8c87d6ba | ||
|
|
ca462fb3e7 | ||
|
|
b9c0cccf1a | ||
|
|
cd451dd31e | ||
|
|
2189459744 | ||
|
|
15b15b39b3 | ||
|
|
42ef031fab | ||
|
|
2a7dbdce28 | ||
|
|
e80b91fbd4 | ||
|
|
1d4ac086fe | ||
|
|
f9338127bb | ||
|
|
f5bd4814c3 | ||
|
|
c24c901a2f |
1
deps.edn
1
deps.edn
@@ -170,6 +170,7 @@
|
||||
org.eclipse.jetty/jetty-unixdomain-server {:mvn/version "12.0.25"}
|
||||
org.eclipse.jetty.ee9/jetty-ee9-servlet {:mvn/version "12.0.24"}
|
||||
org.eclipse.jetty.ee9.websocket/jetty-ee9-websocket-jetty-server {:mvn/version "12.0.25"}
|
||||
org.eclipse.jgit/org.eclipse.jgit {:mvn/version "7.3.0.202506031305-r"}; Java implementation of Git version control system
|
||||
org.flatland/ordered {:mvn/version "1.15.12"} ; ordered maps & sets
|
||||
org.graalvm.polyglot/js-community {:mvn/version "24.2.2" :extension "pom"} ; JavaScript engine
|
||||
org.graalvm.polyglot/polyglot {:mvn/version "24.2.2"} ; GraalVM platform, required by JS engine
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
[clojure.main]
|
||||
[clojure.string :as str]
|
||||
[clojure.test]
|
||||
[dev.branching :as dev.branching]
|
||||
[dev.debug-qp :as debug-qp]
|
||||
[dev.explain :as dev.explain]
|
||||
[dev.h2 :as dev.h2]
|
||||
|
||||
198
dev/src/dev/branching.clj
Normal file
198
dev/src/dev/branching.clj
Normal file
@@ -0,0 +1,198 @@
|
||||
(ns dev.branching
|
||||
(:require
|
||||
[metabase.branching.core :as branching]
|
||||
[metabase.test.initialize.test-users :as tu]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn create-sample-branch-setup!
|
||||
"Creates a development branch setup with cards, copies, and mappings.
|
||||
This is a development/testing version that simulates branching functionality
|
||||
without requiring the enterprise branching models.
|
||||
|
||||
Returns a map with:
|
||||
- :branch - simulated branch data
|
||||
- :original-cards - the original card instances
|
||||
- :copied-cards - the copied card instances
|
||||
- :mappings - simulated branch model mappings
|
||||
- :dashboard - dashboard referencing original cards"
|
||||
[& {:keys [branch-name card-count copy-ratio collection-id]
|
||||
:or {branch-name "dev-branch"
|
||||
card-count 5
|
||||
copy-ratio 0.6
|
||||
collection-id nil}}]
|
||||
(tu/init!)
|
||||
(let [user (t2/select-one :model/User :email "rasta@metabase.com")
|
||||
branch (t2/insert-returning-instance! :model/Branch {:name branch-name})
|
||||
|
||||
;; Determine collection
|
||||
target-collection (if collection-id
|
||||
(t2/select-one :model/Collection :id collection-id)
|
||||
(t2/insert-returning-instance! :model/Collection
|
||||
{:name (str "Branch Collection - " branch-name)}))
|
||||
|
||||
;; Create original cards
|
||||
original-cards (vec
|
||||
(for [i (range card-count)]
|
||||
(t2/insert-returning-instance! :model/Card
|
||||
{:name (str "Original Card " (inc i))
|
||||
:collection_id (:id target-collection)
|
||||
:dataset_query {:database 1
|
||||
:type :native
|
||||
:native {:query (str "SELECT " (inc i) " as result")}}
|
||||
:display "table"
|
||||
:visualization_settings {}
|
||||
:creator_id (:id user)})))
|
||||
|
||||
;; Determine which cards to copy based on ratio
|
||||
cards-to-copy-count (int (* card-count copy-ratio))
|
||||
cards-to-copy (take cards-to-copy-count original-cards)
|
||||
|
||||
;; Create copied cards
|
||||
copied-cards (vec
|
||||
(for [original-card cards-to-copy]
|
||||
(t2/insert-returning-instance! :model/Card
|
||||
{:name (str "Branched " (:name original-card))
|
||||
:collection_id (:id target-collection)
|
||||
:dataset_query (update (:dataset_query original-card)
|
||||
:native
|
||||
assoc :query "SELECT 420 as result -- BRANCHED VERSION")
|
||||
:display (:display original-card)
|
||||
:visualization_settings (:visualization_settings original-card)
|
||||
:creator_id (:id user)})))
|
||||
|
||||
;; Simulate branch model mappings (as data, not DB records)
|
||||
mappings (vec
|
||||
(for [[original copied] (map vector cards-to-copy copied-cards)]
|
||||
(t2/insert-returning-instance! :model/BranchModelMapping
|
||||
{:branch_id (:id branch)
|
||||
:model_type "report_card"
|
||||
:original_id (:id original)
|
||||
:branched_model_id (:id copied)})))
|
||||
|
||||
;; Create dashboard referencing original cards
|
||||
dashboard (t2/insert-returning-instance! :model/Dashboard
|
||||
{:name (str "Dashboard for " branch-name)
|
||||
:collection_id (:id target-collection)
|
||||
:creator_id (:id user)})
|
||||
|
||||
;; Create dashboard cards linking to original cards
|
||||
dashcards (vec
|
||||
(for [[idx original-card] (map-indexed vector original-cards)]
|
||||
(t2/insert-returning-instance! :model/DashboardCard
|
||||
{:dashboard_id (:id dashboard)
|
||||
:card_id (:id original-card)
|
||||
:row (* idx 2)
|
||||
:col 0
|
||||
:size_x 4
|
||||
:size_y 2})))]
|
||||
|
||||
{:branch branch
|
||||
:collection target-collection
|
||||
:original-cards original-cards
|
||||
:copied-cards copied-cards
|
||||
:mappings mappings
|
||||
:dashboard dashboard
|
||||
:dashcards dashcards}))
|
||||
|
||||
(defn resolve-branched-id
|
||||
"Simulated version of resolve-branched-id that uses in-memory mappings."
|
||||
[mappings model-type original-id branch-id]
|
||||
(or (->> mappings
|
||||
(filter #(and (= (:model_type %) (name model-type))
|
||||
(= (:original_id %) original-id)
|
||||
(= (:branch_id %) branch-id)))
|
||||
first
|
||||
:branched_model_id)
|
||||
original-id))
|
||||
|
||||
(defn print-branch-summary
|
||||
"Prints a summary of the created branch setup."
|
||||
[{:keys [branch collection original-cards copied-cards mappings dashboard]}]
|
||||
(println "=== Branch Setup Summary ===")
|
||||
(println (format "Branch: %s (ID: %s, Slug: %s)"
|
||||
(:name branch) (:id branch) (:slug branch)))
|
||||
(println (format "Collection: %s (ID: %s)"
|
||||
(:name collection) (:id collection)))
|
||||
(println (format "Original Cards: %d" (count original-cards)))
|
||||
(println (format "Copied Cards: %d" (count copied-cards)))
|
||||
(println (format "Mappings: %d" (count mappings)))
|
||||
(println (format "Dashboard: %s (ID: %s)"
|
||||
(:name dashboard) (:id dashboard)))
|
||||
(println "\n=== Card Mappings ===")
|
||||
(doseq [{:keys [original_id branched_model_id]} mappings]
|
||||
(let [original-name (:name (t2/select-one :model/Card :id original_id))
|
||||
branched-name (:name (t2/select-one :model/Card :id branched_model_id))]
|
||||
(println (format "%s -> %s" original-name branched-name))))
|
||||
|
||||
(println "\n=== Example Usage ===")
|
||||
(println "To resolve branched IDs:")
|
||||
(doseq [card original-cards]
|
||||
(let [resolved-id (resolve-branched-id mappings :card (:id card) (:id branch))]
|
||||
(if (not= resolved-id (:id card))
|
||||
(println (format "Card %d resolves to branched card %d" (:id card) resolved-id))
|
||||
(println (format "Card %d has no branch mapping, uses original" (:id card)))))))
|
||||
|
||||
(defn cleanup-branch-setup!
|
||||
"Cleans up a branch setup by deleting all created entities."
|
||||
[{:keys [branch collection original-cards copied-cards mappings dashboard dashcards]}]
|
||||
(println "Cleaning up branch setup...")
|
||||
|
||||
;; Delete in reverse dependency order
|
||||
(when dashcards
|
||||
(doseq [dashcard dashcards]
|
||||
(t2/delete! :model/DashboardCard :id (:id dashcard))))
|
||||
|
||||
(when dashboard
|
||||
(t2/delete! :model/Dashboard :id (:id dashboard)))
|
||||
|
||||
;; Note: mappings are just data, no DB cleanup needed
|
||||
|
||||
(when copied-cards
|
||||
(doseq [card copied-cards]
|
||||
(t2/delete! :model/Card :id (:id card))))
|
||||
|
||||
(when original-cards
|
||||
(doseq [card original-cards]
|
||||
(t2/delete! :model/Card :id (:id card))))
|
||||
|
||||
(when collection
|
||||
(t2/delete! :model/Collection :id (:id collection)))
|
||||
|
||||
;; Note: branch is simulated data, no DB cleanup needed
|
||||
|
||||
(println "Cleanup complete."))
|
||||
|
||||
(comment
|
||||
;; Example usage:
|
||||
|
||||
;; Create a branch setup
|
||||
(def setup (create-sample-branch-setup!))
|
||||
|
||||
;; Print summary
|
||||
(print-branch-summary setup)
|
||||
|
||||
;; Test resolving branched IDs
|
||||
(resolve-branched-id (:mappings setup) :card
|
||||
(-> setup :original-cards first :id)
|
||||
(-> setup :branch :id))
|
||||
|
||||
;; Create with custom options
|
||||
(def custom-setup (create-sample-branch-setup!
|
||||
:branch-name "feature-xyz"
|
||||
:card-count 8
|
||||
:copy-ratio 0.5))
|
||||
|
||||
;; Clean up when done
|
||||
(cleanup-branch-setup! setup)
|
||||
(cleanup-branch-setup! custom-setup))
|
||||
|
||||
(comment
|
||||
(binding [branching/*enable-branch-hook* true
|
||||
branching/*current-branch* (atom (t2/select-one :model/Branch :id 392))]
|
||||
(t2/debug
|
||||
(time (t2/select-one :model/Card :id 75)))))
|
||||
|
||||
(comment
|
||||
(let [cards (t2/select :model/Card)]
|
||||
(binding [branching/*enable-branch-hook* false]
|
||||
(time (count (map #(t2/select-one :model/Card :id %) (map :id (mapcat #(repeat 5 %) cards))))))))
|
||||
@@ -14,6 +14,7 @@
|
||||
[metabase-enterprise.api.routes.common :as ee.api.common]
|
||||
[metabase-enterprise.audit-app.api.routes]
|
||||
[metabase-enterprise.billing.api.routes]
|
||||
[metabase-enterprise.branching.api]
|
||||
[metabase-enterprise.cloud-add-ons.api]
|
||||
[metabase-enterprise.content-translation.routes]
|
||||
[metabase-enterprise.content-verification.api.routes]
|
||||
@@ -21,6 +22,7 @@
|
||||
[metabase-enterprise.database-routing.api]
|
||||
[metabase-enterprise.documents.api]
|
||||
[metabase-enterprise.email.api]
|
||||
[metabase-enterprise.git-source-of-truth.api]
|
||||
[metabase-enterprise.gsheets.api :as gsheets.api]
|
||||
[metabase-enterprise.llm.api]
|
||||
[metabase-enterprise.metabot-v3.api]
|
||||
@@ -91,6 +93,7 @@
|
||||
"/audit-app" (premium-handler metabase-enterprise.audit-app.api.routes/routes :audit-app)
|
||||
"/autodescribe" (premium-handler 'metabase-enterprise.llm.api :llm-autodescription)
|
||||
"/billing" metabase-enterprise.billing.api.routes/routes
|
||||
"/branch" metabase-enterprise.branching.api/routes
|
||||
"/content-translation" (premium-handler metabase-enterprise.content-translation.routes/routes :content-translation)
|
||||
"/cloud-add-ons" metabase-enterprise.cloud-add-ons.api/routes
|
||||
"/database-replication" (-> database-replication.api/routes ;; database-replication requires all these features.
|
||||
@@ -100,6 +103,7 @@
|
||||
"/database-routing" (premium-handler metabase-enterprise.database-routing.api/routes :database-routing)
|
||||
"/document" (premium-handler metabase-enterprise.documents.api/routes :documents)
|
||||
"/email" (premium-handler metabase-enterprise.email.api/routes :cloud-custom-smtp)
|
||||
"/git-source-of-truth" (premium-handler metabase-enterprise.git-source-of-truth.api/routes :serialization)
|
||||
"/gsheets" (-> gsheets.api/routes ;; gsheets requires both features.
|
||||
(premium-handler :attached-dwh)
|
||||
(premium-handler :etl-connections))
|
||||
|
||||
166
enterprise/backend/src/metabase_enterprise/branching/api.clj
Normal file
166
enterprise/backend/src/metabase_enterprise/branching/api.clj
Normal file
@@ -0,0 +1,166 @@
|
||||
(ns metabase-enterprise.branching.api
|
||||
"`/api/ee/branch/` routes"
|
||||
(:require
|
||||
[metabase-enterprise.branching.models.branch :as branch]
|
||||
[metabase-enterprise.branching.models.branch-model-mapping :as mapping]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.branching.core :as branching]
|
||||
[metabase.util.i18n :refer [tru]]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
;;; ------------------------------------------------- Schemas -------------------------------------------------
|
||||
|
||||
(def BranchCreateRequest
|
||||
"Schema for creating a new branch."
|
||||
[:map
|
||||
[:name ms/NonBlankString]
|
||||
[:parent_branch_id {:optional true} [:maybe ms/Int]]])
|
||||
|
||||
(def BranchResponse
|
||||
"Schema for branch API responses."
|
||||
[:map
|
||||
[:id ms/PositiveInt]
|
||||
[:name :string]
|
||||
[:slug :string]
|
||||
[:creator_id ms/PositiveInt]
|
||||
[:parent_branch_id [:maybe ms/PositiveInt]]
|
||||
[:created_at ms/TemporalString]
|
||||
[:updated_at ms/TemporalString]])
|
||||
|
||||
(def DiffResponse
|
||||
"Schema for branch diff API responses."
|
||||
[:map
|
||||
[:id ms/PositiveInt]
|
||||
[:name :string]
|
||||
[:status [:enum "added" "modified" "deleted"]]
|
||||
[:content_type [:enum "card" "dashboard" "collection" "transform"]]
|
||||
[:current {:optional true} :map]
|
||||
[:original {:optional true} :map]])
|
||||
|
||||
;;; ------------------------------------------------- API Endpoints -------------------------------------------------
|
||||
|
||||
(api.macros/defendpoint :get "/"
|
||||
"Get all branches. If parent_id is specified, filter to branches with that parent. Set parent_id to -1 to get root-level branches."
|
||||
[_route-params
|
||||
{:keys [parent_id]} :- [:map
|
||||
[:parent_id {:optional true} [:maybe ms/Int]]]]
|
||||
{:data (into [{:id -1
|
||||
:name "main"
|
||||
:slug "main"
|
||||
:creator_id 1
|
||||
:parent_branch_id nil
|
||||
:created_at "1970-01-01T00:00:00Z"
|
||||
:updated_at "1970-01-01T00:00:00Z"}]
|
||||
(map #(select-keys % [:id :name :slug :creator_id :parent_branch_id :created_at :updated_at])
|
||||
(cond
|
||||
(nil? parent_id) (t2/select :model/Branch)
|
||||
(> 0 parent_id) (branch/get-children-by-id nil)
|
||||
:else (branch/get-children-by-id parent_id))))})
|
||||
|
||||
(api.macros/defendpoint :get "/:id/diff"
|
||||
"Get diff of changes in a branch compared to its parent branch. Open access for authenticated users."
|
||||
[{:keys [id]} :- [:map [:id ms/PositiveInt]]]
|
||||
(let [branch (t2/select-one :model/Branch :id id)]
|
||||
(when-not branch
|
||||
(throw (ex-info (tru "Branch not found.") {:status-code 404})))
|
||||
|
||||
(let [mappings (t2/select :model/BranchModelMapping :branch_id id)
|
||||
card-mappings (filter #(= (:model_type %) "report_card") mappings)
|
||||
transform-mappings (filter #(= (:model_type %) "transform") mappings)
|
||||
|
||||
card-diff-entries (for [{:keys [original_id branched_model_id]} card-mappings]
|
||||
(let [is-added? (= original_id branched_model_id)
|
||||
is-deleted? (and original_id (nil? branched_model_id))
|
||||
status (cond
|
||||
is-added? "added"
|
||||
is-deleted? "deleted"
|
||||
:else "modified")
|
||||
original-card (when (and original_id (not is-added?))
|
||||
(binding [branching/*enable-branch-hook* false]
|
||||
(t2/select-one :model/Card :id original_id)))
|
||||
current-card (when branched_model_id
|
||||
(binding [branching/*enable-branch-hook* false]
|
||||
(t2/select-one :model/Card :id branched_model_id)))
|
||||
card-fields [:id :name :description :dataset_query :display :visualization_settings :database_id]]
|
||||
|
||||
{:id (or branched_model_id original_id)
|
||||
:name (or (:name current-card) (:name original-card) "Unknown")
|
||||
:status status
|
||||
:content_type "card"
|
||||
:current (when current-card (select-keys current-card card-fields))
|
||||
:original (when original-card (select-keys original-card card-fields))}))
|
||||
|
||||
transform-diff-entries (for [{:keys [original_id branched_model_id]} transform-mappings]
|
||||
(let [is-added? (= original_id branched_model_id)
|
||||
is-deleted? (and original_id (nil? branched_model_id))
|
||||
status (cond
|
||||
is-added? "added"
|
||||
is-deleted? "deleted"
|
||||
:else "modified")
|
||||
original-transform (when (and original_id (not is-added?))
|
||||
(binding [branching/*enable-branch-hook* false]
|
||||
(t2/select-one :model/Transform :id original_id)))
|
||||
current-transform (when branched_model_id
|
||||
(binding [branching/*enable-branch-hook* false]
|
||||
(t2/select-one :model/Transform :id branched_model_id)))
|
||||
transform-fields [:id :name :description :source :target :run_trigger]]
|
||||
|
||||
{:id (or branched_model_id original_id)
|
||||
:name (or (:name current-transform) (:name original-transform) "Unknown")
|
||||
:status status
|
||||
:content_type "transform"
|
||||
:current (when current-transform (select-keys current-transform transform-fields))
|
||||
:original (when original-transform (select-keys original-transform transform-fields))}))
|
||||
|
||||
all-entries (concat card-diff-entries transform-diff-entries)]
|
||||
|
||||
{:data all-entries})))
|
||||
|
||||
(api.macros/defendpoint :get "/:id"
|
||||
"Get a single branch by ID."
|
||||
[{:keys [id]} :- [:map [:id ms/PositiveInt]]]
|
||||
(or (some-> (t2/select-one :model/Branch :id id)
|
||||
(select-keys [:id :name :slug :creator_id :parent_branch_id :created_at :updated_at]))
|
||||
(throw (ex-info (tru "Branch not found.") {:status-code 404}))))
|
||||
|
||||
(api.macros/defendpoint :post "/"
|
||||
"Create a new branch."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [name parent_branch_id]} :- BranchCreateRequest]
|
||||
(let [parent_branch_id (if (= -1 parent_branch_id) nil parent_branch_id)]
|
||||
(when parent_branch_id
|
||||
;; Validate parent branch exists
|
||||
(when-not (t2/exists? :model/Branch :id parent_branch_id)
|
||||
(throw (ex-info (tru "Parent branch does not exist.") {:status-code 400}))))
|
||||
|
||||
;; Create the branch
|
||||
(let [branch-data (cond-> {:name name
|
||||
:creator_id api/*current-user-id*}
|
||||
parent_branch_id (assoc :parent_branch_id parent_branch_id))
|
||||
new-id (t2/insert-returning-pk! :model/Branch branch-data)]
|
||||
(-> (t2/select-one :model/Branch :id new-id)
|
||||
(select-keys [:id :name :slug :creator_id :parent_branch_id :created_at :updated_at])))))
|
||||
|
||||
(api.macros/defendpoint :delete "/:id"
|
||||
"Delete a branch by ID. Open access for authenticated users."
|
||||
[{:keys [id]} :- [:map [:id ms/PositiveInt]]]
|
||||
(let [branch (t2/select-one :model/Branch :id id)]
|
||||
(when-not branch
|
||||
(throw (ex-info (tru "Branch not found.") {:status-code 404})))
|
||||
|
||||
;; Check if there are child branches
|
||||
(when (branch/get-has-children? branch)
|
||||
(throw (ex-info (tru "Cannot delete branch with child branches.") {:status-code 400})))
|
||||
|
||||
(t2/delete! :model/Branch :id id)
|
||||
api/generic-204-no-content))
|
||||
|
||||
;;; ------------------------------------------------- Routes -------------------------------------------------
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/branch/` routes."
|
||||
(api.macros/ns-handler *ns* +auth))
|
||||
@@ -0,0 +1,14 @@
|
||||
(ns metabase-enterprise.branching.core
|
||||
"Handles the logic for setting the current branch from an HTTP request and supports
|
||||
overriding t2/select to get branch-able models based on the current request."
|
||||
(:require
|
||||
[metabase.premium-features.core :refer [defenterprise]]
|
||||
[metabase.users.settings :as u.settings]))
|
||||
|
||||
(defenterprise get-current-branch
|
||||
"Get the current branch from the X-Metabase-Branch header."
|
||||
:feature :none
|
||||
[request]
|
||||
(or (get-in request [:headers "X-Metabase-Branch"])
|
||||
(get-in request [:headers "x-metabase-branch"])
|
||||
(u.settings/git-branch)))
|
||||
@@ -0,0 +1,3 @@
|
||||
(ns metabase-enterprise.branching.init
|
||||
(:require
|
||||
[metabase-enterprise.branching.models.hook]))
|
||||
@@ -0,0 +1,63 @@
|
||||
(ns metabase-enterprise.branching.models.branch
|
||||
(:require
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(methodical/defmethod t2/table-name :model/Branch
|
||||
[_model] :branch)
|
||||
|
||||
(doto :model/Branch
|
||||
(derive :metabase/model)
|
||||
(derive :hook/timestamped?))
|
||||
|
||||
(t2/define-before-insert :model/Branch
|
||||
[branch]
|
||||
(assoc branch :slug (u/slugify (:name branch))))
|
||||
|
||||
;;; ------------------------------------------------- Helper Functions -------------------------------------------------
|
||||
|
||||
(mu/defn get-parent :- [:maybe (ms/InstanceOf :model/Branch)]
|
||||
"Get the parent branch of the given branch, if it exists."
|
||||
[branch :- (ms/InstanceOf :model/Branch)]
|
||||
(when-let [parent-id (:parent_branch_id branch)]
|
||||
(t2/select-one :model/Branch :id parent-id)))
|
||||
|
||||
(mu/defn get-children-by-id :- [:set (ms/InstanceOf :model/Branch)]
|
||||
"Get all branches with the specified parent_branch_id. If parent-id is nil,
|
||||
returns root-level branches (those with no parent)."
|
||||
[parent-id :- [:maybe ms/PositiveInt]]
|
||||
(set (t2/select :model/Branch :parent_branch_id parent-id)))
|
||||
|
||||
(mu/defn get-children :- [:set (ms/InstanceOf :model/Branch)]
|
||||
"Get all direct child branches of the given branch."
|
||||
[branch :- (ms/InstanceOf :model/Branch)]
|
||||
(get-children-by-id (:id branch)))
|
||||
|
||||
(mu/defn get-has-children? :- :boolean
|
||||
"Check if the given branch has any child branches."
|
||||
[branch :- (ms/InstanceOf :model/Branch)]
|
||||
(t2/exists? :model/Branch :parent_branch_id (:id branch)))
|
||||
|
||||
;;; ------------------------------------------------- Hydration Methods -------------------------------------------------
|
||||
|
||||
(mi/define-simple-hydration-method parent
|
||||
:parent
|
||||
"Hydrate a branch with its parent branch."
|
||||
[branch]
|
||||
(get-parent branch))
|
||||
|
||||
(mi/define-simple-hydration-method children
|
||||
:children
|
||||
"Hydrate a branch with its direct child branches."
|
||||
[branch]
|
||||
(get-children branch))
|
||||
|
||||
(mi/define-simple-hydration-method has-children?
|
||||
:has_children
|
||||
"Hydrate a branch with a boolean indicating whether it has child branches."
|
||||
[branch]
|
||||
(get-has-children? branch))
|
||||
@@ -0,0 +1,31 @@
|
||||
(ns metabase-enterprise.branching.models.branch-model-mapping
|
||||
(:require
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(methodical/defmethod t2/table-name :model/BranchModelMapping
|
||||
[_model] :branch_model_mapping)
|
||||
|
||||
(doto :model/BranchModelMapping
|
||||
(derive :metabase/model)
|
||||
(derive :hook/timestamped?))
|
||||
|
||||
(defn resolve-branched-id
|
||||
"Given an original model ID and branch, return the branched ID if it exists"
|
||||
[model-type original-id branch-id]
|
||||
(or (t2/select-one-fn :branched_model_id :model/BranchModelMapping
|
||||
:original_id original-id
|
||||
:model_type (name model-type)
|
||||
:branch_id branch-id)
|
||||
original-id))
|
||||
|
||||
(defn batch-resolve-branched-ids
|
||||
"Efficiently resolve multiple IDs for a branch"
|
||||
[model-type original-ids branch-id]
|
||||
(when (seq original-ids)
|
||||
(let [mappings (t2/select :model/BranchModelMapping
|
||||
:model_type (name model-type)
|
||||
:original_id [:in original-ids]
|
||||
:branch_id branch-id)
|
||||
mapping-map (into {} (map (juxt :original_id :branched_model_id)) mappings)]
|
||||
(map #(get mapping-map % %) original-ids))))
|
||||
@@ -0,0 +1,183 @@
|
||||
(ns metabase-enterprise.branching.models.hook
|
||||
"Defines a toucan pipeline step hook that substitutes out tables that can be branched
|
||||
with CTEs that reflect their current state when branched."
|
||||
(:require
|
||||
[clojure.java.jdbc :as jdbc]
|
||||
[clojure.string :as str]
|
||||
[clojure.walk :as walk]
|
||||
[honey.sql :as sql]
|
||||
[medley.core :as m]
|
||||
[metabase.app-db.core :as mdb]
|
||||
[metabase.branching.core :as branching]
|
||||
[metabase.models.resolution :as models.resolution]
|
||||
[metabase.util :as u]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]
|
||||
[toucan2.pipeline :as t2.pipeline]
|
||||
[toucan2.query :as t2.query]))
|
||||
|
||||
(def ^:private branchables
|
||||
"Map of tables derving from :hook/branchable -> their columns."
|
||||
(delay
|
||||
(reduce-kv (fn [acc k _]
|
||||
(cond-> acc
|
||||
(isa? k :hook/branchable) (assoc (t2/table-name k) (map :column_name
|
||||
(t2/query {:select [:column_name]
|
||||
:from [[:information_schema.columns]]
|
||||
:where [:and [:= :table_name (case (mdb/db-type)
|
||||
:h2 (u/upper-case-en (name (t2/table-name k)))
|
||||
(name (t2/table-name k)))]]})))))
|
||||
{}
|
||||
models.resolution/model->namespace)))
|
||||
|
||||
(defn- branched-cte
|
||||
[table]
|
||||
(let [current-branch-id (:id @branching/*current-branch*)]
|
||||
{:select (into [[[:coalesce :bm.original_id :branched.id] :id]]
|
||||
(for [col-name (table @branchables)
|
||||
:when (not (contains? #{"id" "ID"} col-name))]
|
||||
(keyword (str "branched." col-name))))
|
||||
:from [[table :branched]]
|
||||
:left-join [[:branch_model_mapping :bm]
|
||||
[:and
|
||||
[:= :bm.branched_model_id :branched.id]
|
||||
[:= :bm.model_type [:inline (name table)]]]]
|
||||
:where [:or
|
||||
[:and [:is-not :bm.branched_model_id nil]
|
||||
[:= :bm.branch_id current-branch-id]]
|
||||
[:and
|
||||
[:is :bm.branched_model_id nil]
|
||||
[:not-exists
|
||||
{:select [1]
|
||||
:from [[:branch_model_mapping :bm2]]
|
||||
:where [:and
|
||||
[:= :bm2.branch_id current-branch-id]
|
||||
[:= :bm2.original_id :branched.id]
|
||||
[:= :bm2.model_type [:inline (name table)]]]}]]]}))
|
||||
|
||||
(defn- substitute-ctes
|
||||
"Substitute out branchable tables in a query with their replacements."
|
||||
[query tables]
|
||||
(let [with (some->> query :with (m/index-by first))
|
||||
tables* (remove #(contains? with (keyword (str "branched_" (name %)))) tables)
|
||||
tables->cte-refs (zipmap tables* (map #(keyword (str "branched_" (name %))) tables*))
|
||||
str-tables->cte-refs (reduce-kv #(assoc %1 (name %2) (name %3)) {} tables->cte-refs)
|
||||
replaced (atom #{})]
|
||||
(letfn [(walker [node]
|
||||
(cond
|
||||
(not (keyword? node)) node
|
||||
(contains? tables->cte-refs node)
|
||||
(do (swap! replaced conj node)
|
||||
(node tables->cte-refs))
|
||||
|
||||
(contains? str-tables->cte-refs (namespace node))
|
||||
(keyword (get str-tables->cte-refs (namespace node)) (name node))
|
||||
|
||||
(some #(str/starts-with? (name node) (str % ".")) (keys str-tables->cte-refs))
|
||||
(let [[table col] (str/split (name node) #"\." 2)]
|
||||
(keyword (str (get str-tables->cte-refs table) "." col)))
|
||||
|
||||
:else node))]
|
||||
(if (empty? tables*)
|
||||
query
|
||||
(let [walked (walk/postwalk walker query)]
|
||||
(reduce (fn [query* table]
|
||||
(update query* :with conj [(table tables->cte-refs) (branched-cte table)]))
|
||||
walked
|
||||
@replaced))))))
|
||||
|
||||
(methodical/defmethod t2.pipeline/build [#_query-type :default
|
||||
#_model :default
|
||||
#_resolved-query :default]
|
||||
[query-type model parsed-args resolved-query]
|
||||
(let [built (next-method query-type model parsed-args resolved-query)]
|
||||
(if (and branching/*enable-branch-hook*
|
||||
(not= query-type :toucan2.tools.before-update/select-for-before-update)
|
||||
(map? built)
|
||||
(not= model :model/Branch)
|
||||
(or (contains? built :select) (contains? built :union) (contains? built :union-all))
|
||||
(not= (:from built) [[:information_schema.columns]]))
|
||||
(substitute-ctes built (keys @branchables))
|
||||
built)))
|
||||
|
||||
(defn- model->type
|
||||
"Returns the model_type for the branch mapping for the given model."
|
||||
[model]
|
||||
(-> model
|
||||
t2/table-name
|
||||
name))
|
||||
|
||||
(defn- instance->type
|
||||
"Returns the model_type for the branch mapping for the given instance."
|
||||
[instance]
|
||||
(-> instance
|
||||
t2/model
|
||||
model->type))
|
||||
|
||||
(t2/define-after-insert :hook/branchable
|
||||
[instance]
|
||||
(when @branching/*current-branch*
|
||||
(t2/insert! :model/BranchModelMapping {:original_id (:id instance)
|
||||
:branched_model_id (:id instance)
|
||||
:model_type (instance->type instance)
|
||||
:branch_id (:id @branching/*current-branch*)}))
|
||||
instance)
|
||||
|
||||
;(defn- on-current-branch?
|
||||
; [{:keys [id] :as instance}]
|
||||
; (and branching/*current-branch*
|
||||
; (t2/exists? :model/BranchModelMapping :original_id id :branch_id (:id @branching/*current-branch*) :model_type (instance->type instance))))
|
||||
|
||||
(defn- only-not-in-branch [where]
|
||||
(if branching/*current-branch*
|
||||
[:and where [:not-in :id {:select [:original_id]
|
||||
:from [:branch_model_mapping]
|
||||
:where [:= :branch_id (:id @branching/*current-branch*)]}]]
|
||||
where))
|
||||
|
||||
(defn- translate-pk [kv-args]
|
||||
(if branching/*current-branch*
|
||||
|
||||
(m/update-existing kv-args :toucan/pk (fn [x] (or (t2/select-one-fn :branched_model_id [:model/BranchModelMapping :branched_model_id]
|
||||
:original_id x
|
||||
:branch_id (:id @branching/*current-branch*)) x)))
|
||||
kv-args))
|
||||
|
||||
;(defn- add-only-in-branch [where]
|
||||
; (if branching/*current-branch*
|
||||
; [:and where [:in :id {:select [:branched_model_id]
|
||||
; :from [:branch_model_mapping]
|
||||
; :where [:= :branch_id (:id @branching/*current-branch*)]}]]
|
||||
; [:and where [:not-in :id {:select [:branched_model_id]
|
||||
; :from [:branch_model_mapping]}]]))
|
||||
|
||||
(defn- copy-unbranched
|
||||
[model parsed-args]
|
||||
(when @branching/*current-branch*
|
||||
(let [db-type (mdb/db-type)
|
||||
kv-args (:kv-args parsed-args)
|
||||
query (-> (t2.query/apply-kv-args model {} kv-args)
|
||||
(update :where only-not-in-branch))
|
||||
columns (map keyword (remove #(#{"id" "ID"} %) ((t2/table-name model) @branchables)))
|
||||
insert-half (first (sql/format {:insert-into [(t2/table-name model) columns]} {:inline true}))]
|
||||
(t2/with-connection [^java.sql.Connection conn]
|
||||
(doseq [to-copy-id (t2/select-pks-vec model query)]
|
||||
(let [inserted-id (-> (jdbc/execute! {:connection conn} (str insert-half " " (first (sql/format {:select columns
|
||||
:from [(t2/table-name model)]
|
||||
:where [:= :id to-copy-id]} {:inline true}))) {:returning-keys true})
|
||||
first)]
|
||||
(t2/insert! :model/BranchModelMapping {:original_id to-copy-id
|
||||
:branched_model_id (:val (first (t2/query [(str "SELECT max(id) as val from " (name (t2/table-name model)))]))) ;; TODO: this is ugly for h2 vs using "RETURNING ID" and returning-keys seems to lie, is there a better way?
|
||||
:model_type (model->type model)
|
||||
:branch_id (:id @branching/*current-branch*)})))))))
|
||||
|
||||
(methodical/defmethod t2.pipeline/transduce-query [#_query-type :toucan.query-type/update.*
|
||||
#_model :model/Transform ; TODO: make this :hook/branchable
|
||||
#_resolved-query :default]
|
||||
[rf query-type model parsed-args resolved-query]
|
||||
(copy-unbranched model parsed-args)
|
||||
(next-method rf
|
||||
query-type
|
||||
model
|
||||
(update parsed-args :kv-args translate-pk)
|
||||
resolved-query))
|
||||
@@ -0,0 +1,2 @@
|
||||
(ns metabase-enterprise.branching.settings
|
||||
"Settings definitions that control whether branching is on and how it is implemented.")
|
||||
@@ -9,9 +9,11 @@
|
||||
[metabase-enterprise.action-v2.init]
|
||||
[metabase-enterprise.advanced-config.init]
|
||||
[metabase-enterprise.audit-app.init]
|
||||
[metabase-enterprise.branching.init]
|
||||
[metabase-enterprise.cache.init]
|
||||
[metabase-enterprise.database-replication.init]
|
||||
[metabase-enterprise.documents.init]
|
||||
[metabase-enterprise.git-source-of-truth.init]
|
||||
[metabase-enterprise.gsheets.init]
|
||||
[metabase-enterprise.llm.init]
|
||||
[metabase-enterprise.metabot-v3.init]
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
(ns metabase-enterprise.git-source-of-truth.api
|
||||
(:require
|
||||
[clojure.java.io :as io]
|
||||
[clojure.string :as str]
|
||||
[metabase-enterprise.git-source-of-truth.settings :as settings]
|
||||
[metabase-enterprise.git-source-of-truth.sources :as sources]
|
||||
[metabase-enterprise.serialization.v2.extract :as serdes.extract]
|
||||
[metabase-enterprise.serialization.v2.ingest :as serdes.ingest]
|
||||
[metabase-enterprise.serialization.v2.load :as serdes.load]
|
||||
[metabase-enterprise.serialization.v2.storage :as serdes.storage]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.models.serialization :as serdes]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.log :as log])
|
||||
(:import (java.io File)
|
||||
(java.nio.file Files)
|
||||
(java.nio.file.attribute FileAttribute)))
|
||||
|
||||
(defn cleanup-temp-directory!
|
||||
"Clean up temporary directory and all its contents."
|
||||
[temp-dir]
|
||||
(when temp-dir
|
||||
(try
|
||||
(let [dir-file (io/file temp-dir)]
|
||||
(when (.exists ^File dir-file)
|
||||
(log/debugf "Cleaning up temporary directory: %s" temp-dir)
|
||||
|
||||
(letfn [(delete-file [file]
|
||||
(when (.isDirectory file)
|
||||
(doseq [child (.listFiles file)]
|
||||
(delete-file child)))
|
||||
(.delete file))]
|
||||
(delete-file dir-file))
|
||||
|
||||
(log/debugf "Successfully cleaned up temporary directory: %s" temp-dir)))
|
||||
(catch Exception e
|
||||
(log/warnf e "Failed to clean up temporary directory: %s" temp-dir)))))
|
||||
|
||||
(defmacro with-temp-directory
|
||||
"Makes a temp dir and then deletes it outside the form."
|
||||
[binding & body]
|
||||
`(let [~binding (.toFile (Files/createTempDirectory "git-source-of-truth-" (into-array FileAttribute [])))]
|
||||
(try ~@body
|
||||
(finally
|
||||
(cleanup-temp-directory! ~binding)))))
|
||||
|
||||
(defn- reload-from-git!
|
||||
"Reloads the Metabase entities from the library"
|
||||
[]
|
||||
(if-let [source (sources/get-source)]
|
||||
(with-temp-directory dir
|
||||
(log/info "Reloading Metabase configuration from source")
|
||||
(try
|
||||
(serdes/with-cache
|
||||
(serdes.load/load-metabase!
|
||||
(serdes.ingest/ingest-yaml (sources/load-source! source (.toString dir)))))
|
||||
(log/info "Successfully reloaded entities from git repository")
|
||||
{:status :success
|
||||
:message "Successfully reloaded from git repository"}
|
||||
|
||||
(catch Exception e
|
||||
(log/errorf e "Failed to reload from git repository: %s" (.getMessage e))
|
||||
(let [error-msg (cond
|
||||
(instance? java.net.UnknownHostException e)
|
||||
"Network error: Unable to reach git repository host"
|
||||
|
||||
(str/includes? (.getMessage e) "Authentication failed")
|
||||
"Authentication failed: Please check your git credentials"
|
||||
|
||||
(str/includes? (.getMessage e) "Repository not found")
|
||||
"Repository not found: Please check the repository URL"
|
||||
|
||||
(str/includes? (.getMessage e) "branch")
|
||||
"Branch error: Please check the specified branch exists"
|
||||
|
||||
:else
|
||||
(format "Failed to reload from git repository: %s" (.getMessage e)))]
|
||||
{:status :error
|
||||
:message error-msg
|
||||
:details {:error-type (type e)}}))))
|
||||
{:status :error
|
||||
:message "Git source of truth is not enabled. Please configure MB_GIT_SOURCE_REPO_URL environment variable."}))
|
||||
|
||||
(api.macros/defendpoint :post "/import"
|
||||
"Reload Metabase content from Git repository source of truth.
|
||||
|
||||
This endpoint will:
|
||||
1. Fetch the latest changes from the configured git repository
|
||||
2. Load the updated content using the serialization/deserialization system
|
||||
|
||||
Requires superuser permissions."
|
||||
[]
|
||||
(api/check-superuser)
|
||||
(let [result (reload-from-git!)]
|
||||
(case (:status result)
|
||||
:success
|
||||
"Success"
|
||||
|
||||
:error
|
||||
{:status 400
|
||||
:body {:status "error"
|
||||
:message (:message result)}}
|
||||
|
||||
;; Fallback for unexpected result format
|
||||
{:status 500
|
||||
:body {:status "error"
|
||||
:message "Unexpected error occurred during reload"}})))
|
||||
|
||||
(api.macros/defendpoint :post "/export"
|
||||
"Exports Metabase content to the Git repository source of truth.
|
||||
|
||||
Requires superuser"
|
||||
[]
|
||||
(api/check-superuser)
|
||||
;; Placeholder
|
||||
(with-temp-directory tmp
|
||||
(serdes/with-cache
|
||||
(-> (serdes.extract/extract {:no-settings true
|
||||
:include-database-secrets false})
|
||||
(serdes.storage/store! tmp)))
|
||||
|
||||
(sources/push-branch! (sources/get-source) tmp))
|
||||
"Success")
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/git-source-of-truth` routes."
|
||||
(api.macros/ns-handler *ns* +auth))
|
||||
@@ -0,0 +1,7 @@
|
||||
(ns metabase-enterprise.git-source-of-truth.core
|
||||
(:require
|
||||
[metabase-enterprise.serialization.cmd :as serdes-cmd]
|
||||
[metabase.cloud-migration.core :as cloud-migration]
|
||||
[metabase.util.log :as log]
|
||||
[potemkin :as p]))
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
(ns metabase-enterprise.git-source-of-truth.git
|
||||
(:require
|
||||
[clojure.java.io :as io]
|
||||
[metabase.util.log :as log])
|
||||
(:import
|
||||
(java.io File)
|
||||
(org.eclipse.jgit.api Git)
|
||||
(org.eclipse.jgit.api.errors GitAPIException InvalidRemoteException TransportException)
|
||||
(org.eclipse.jgit.transport UsernamePasswordCredentialsProvider
|
||||
RefSpec)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn clone-repository!
|
||||
"Clone git repository to a temporary directory using jgit. Returns the path to the cloned repository."
|
||||
[url branch dir token]
|
||||
(try
|
||||
(log/info "Cloning repository" {:url url :branch branch :dir dir})
|
||||
(-> (Git/cloneRepository)
|
||||
(.setURI url)
|
||||
(.setBranch branch)
|
||||
(.setDirectory (io/file dir))
|
||||
(.setCloneAllBranches false)
|
||||
(.setCredentialsProvider (when token
|
||||
(UsernamePasswordCredentialsProvider. "x-access-token" token)))
|
||||
(.call)
|
||||
(.close))
|
||||
(log/info "Successfully cloned repository" {:dir dir})
|
||||
(catch Exception e
|
||||
(throw (ex-info (format "Failed to clone git repository: %s" (.getMessage e))
|
||||
{:url url
|
||||
:branch branch
|
||||
:dir dir
|
||||
:error (.getMessage e)})))))
|
||||
|
||||
(defn push-branch!
|
||||
"Oh this is terrible, not just pushing a branch but ... sticking the current content into a branch based on
|
||||
`source-branch`, then force pushing it to the `dest-branch`."
|
||||
[{:keys [source-dir url source-branch dest-branch token]}]
|
||||
(let [branch-name (str (random-uuid))
|
||||
repo-dir (io/file source-dir)]
|
||||
(try
|
||||
;; Initialize or open existing repository
|
||||
(log/info "Initializing git repository" {:dir source-dir})
|
||||
(let [git-repo (if (.exists (io/file source-dir ".git"))
|
||||
(Git/open repo-dir)
|
||||
(-> (Git/init)
|
||||
(.setDirectory repo-dir)
|
||||
(.call)))
|
||||
repo (.getRepository git-repo)]
|
||||
(try
|
||||
;; Add origin remote if it doesn't exist
|
||||
(log/info "Setting up remote origin" {:url url})
|
||||
(-> (.remoteAdd git-repo)
|
||||
(.setName "origin")
|
||||
(.setUri (org.eclipse.jgit.transport.URIish. url))
|
||||
(.call))
|
||||
|
||||
;; Fetch from origin
|
||||
(log/info "Fetching from origin")
|
||||
(-> (.fetch git-repo)
|
||||
(.setRemote "origin")
|
||||
(.setCredentialsProvider (when token
|
||||
(UsernamePasswordCredentialsProvider. "x-access-token" token)))
|
||||
(.call))
|
||||
|
||||
;; Create and checkout new branch
|
||||
(log/info "Creating new branch" {:branch branch-name})
|
||||
(-> (.checkout git-repo)
|
||||
(.setOrphan true)
|
||||
(.setName branch-name)
|
||||
(.call))
|
||||
|
||||
(when-let [origin-ref (.findRef repo (str "origin/" source-branch))]
|
||||
(-> (.reset git-repo)
|
||||
(.setMode org.eclipse.jgit.api.ResetCommand$ResetType/MIXED)
|
||||
(.setRef (str "origin/" source-branch))
|
||||
(.call)))
|
||||
|
||||
;; Add all changes
|
||||
(log/info "Adding all changes")
|
||||
(-> (.add git-repo)
|
||||
(.addFilepattern ".")
|
||||
(.call))
|
||||
|
||||
;; Commit changes
|
||||
(log/info "Committing changes")
|
||||
(-> (.commit git-repo)
|
||||
(.setMessage "Update content")
|
||||
(.call))
|
||||
|
||||
;; Push to destination branch
|
||||
(log/info "Pushing to destination branch" {:dest-branch dest-branch})
|
||||
(-> (.push git-repo)
|
||||
(.setRemote "origin")
|
||||
(.setRefSpecs [(RefSpec. (str "refs/heads/" branch-name ":refs/heads/" dest-branch))])
|
||||
(.setForce true)
|
||||
(.setCredentialsProvider
|
||||
(when token
|
||||
(UsernamePasswordCredentialsProvider. "x-access-token" token)))
|
||||
(.call))
|
||||
|
||||
(finally
|
||||
(.close git-repo))))
|
||||
(catch Exception e
|
||||
(log/error e "Git push failed")
|
||||
(throw e)))))
|
||||
|
||||
(defn can-access-branch-in-repository? [git-url branch token]
|
||||
(try
|
||||
(let [credentials-provider (when token (UsernamePasswordCredentialsProvider. "x-access-token" token))
|
||||
ls-remote-command (-> (Git/lsRemoteRepository)
|
||||
(.setRemote git-url)
|
||||
(.setCredentialsProvider credentials-provider))
|
||||
refs (.call ls-remote-command)
|
||||
branch-ref-name (str "refs/heads/" branch)]
|
||||
(boolean (some #(= (.getName %) branch-ref-name) refs)))
|
||||
(catch InvalidRemoteException e false)
|
||||
(catch TransportException e false)
|
||||
(catch GitAPIException e false)))
|
||||
@@ -0,0 +1,3 @@
|
||||
(ns metabase-enterprise.git-source-of-truth.init
|
||||
(:require
|
||||
[metabase-enterprise.git-source-of-truth.settings]))
|
||||
@@ -0,0 +1,82 @@
|
||||
(ns metabase-enterprise.git-source-of-truth.settings
|
||||
(:require
|
||||
[metabase-enterprise.git-source-of-truth.git :as git]
|
||||
[metabase.settings.core :as setting :refer [defsetting]]
|
||||
[metabase.util.i18n :refer [deferred-tru]]))
|
||||
|
||||
(defsetting git-sync-configured
|
||||
(deferred-tru "Whether git sync is configured.")
|
||||
:type :boolean
|
||||
:visibility :authenticated
|
||||
:export? false
|
||||
:encryption :no
|
||||
:default false)
|
||||
|
||||
(defn- set-repo-with-verification!-fn [original-key]
|
||||
(fn [new-value]
|
||||
(setting/set-value-of-type! :string original-key new-value)
|
||||
(if #p (git/can-access-branch-in-repository? (setting/get :git-sync-url)
|
||||
(setting/get :git-sync-import-branch)
|
||||
(setting/get :git-sync-token))
|
||||
(git-sync-configured! true)
|
||||
(git-sync-configured! false))))
|
||||
|
||||
(defsetting git-sync-import-branch
|
||||
(deferred-tru "The git branch to pull from, e.g. `main`")
|
||||
:type :string
|
||||
:visibility :admin
|
||||
:encryption :no
|
||||
:export? false
|
||||
:default "main"
|
||||
:setter (set-repo-with-verification!-fn :git-sync-import-branch))
|
||||
|
||||
(defsetting git-sync-token
|
||||
(deferred-tru "A GH token")
|
||||
:type :string
|
||||
:visibility :admin
|
||||
:doc true
|
||||
:export? false
|
||||
:encryption :when-encryption-key-set
|
||||
:audit :getter
|
||||
:setter (set-repo-with-verification!-fn :git-sync-token))
|
||||
|
||||
(defsetting git-sync-url
|
||||
(deferred-tru "The location of your git repository, e.g. https://github.com/acme-inco/metabase.git")
|
||||
:type :string
|
||||
:visibility :admin
|
||||
:encryption :no
|
||||
:export? false
|
||||
:setter (set-repo-with-verification!-fn :git-sync-url))
|
||||
|
||||
;; TODO actually use this
|
||||
(defsetting git-sync-key
|
||||
(deferred-tru "An RSA key with write permissions to the git repository.")
|
||||
:type :string
|
||||
:visibility :admin
|
||||
:doc true
|
||||
:export? false
|
||||
:encryption :when-encryption-key-set
|
||||
:audit :getter)
|
||||
|
||||
(defsetting git-sync-default-branch
|
||||
(deferred-tru "The default branch for git sync.")
|
||||
:type :string
|
||||
:visibility :authenticated
|
||||
:export? false
|
||||
:encryption :no
|
||||
:default nil)
|
||||
|
||||
(defsetting git-sync-export-branch
|
||||
(deferred-tru "The git branch to push to, e.g. `new-changes`")
|
||||
:type :string
|
||||
:visibility :admin
|
||||
:encryption :no
|
||||
:export? false
|
||||
:default "main")
|
||||
|
||||
(defsetting git-sync-read-only
|
||||
(deferred-tru "Whether the instance is synced to git and read-only")
|
||||
:type :boolean
|
||||
:visibility :admin
|
||||
:export? false
|
||||
:default false)
|
||||
@@ -0,0 +1,62 @@
|
||||
(ns metabase-enterprise.git-source-of-truth.sources
|
||||
(:require
|
||||
[clojure.java.io :as io]
|
||||
[metabase-enterprise.git-source-of-truth.git :as git]
|
||||
[metabase-enterprise.git-source-of-truth.settings :as settings]
|
||||
[metabase.util.log :as log])
|
||||
(:import
|
||||
(java.io File)
|
||||
(org.eclipse.jgit.api Git)
|
||||
(org.eclipse.jgit.transport UsernamePasswordCredentialsProvider
|
||||
RefSpec)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defprotocol ISource
|
||||
(load-source! [this dir]
|
||||
"Loads the source, taking a directory and returning an absolute path to the source directory that can be loaded
|
||||
with serdes.
|
||||
TODO: working with directories (taking a directory and modifying it and returning a path) isn't really the best
|
||||
API but it'll do for now."))
|
||||
|
||||
(defprotocol IDestination
|
||||
(push-branch! [this source-dir]
|
||||
"Pushes the contentents of `source-dir` to the branch specified."))
|
||||
|
||||
(defn get-serdes-path
|
||||
"Get the full path to the serdes files within the repository."
|
||||
[repo-dir path]
|
||||
(let [full-path (if (= path ".")
|
||||
repo-dir
|
||||
(str repo-dir File/separator path))]
|
||||
|
||||
(when-not (.exists (io/file full-path))
|
||||
(throw (ex-info (format "Serdes path does not exist: %s" full-path)
|
||||
{:repo-dir repo-dir
|
||||
:config-path path
|
||||
:full-path full-path})))
|
||||
|
||||
full-path))
|
||||
|
||||
(defrecord GitSource [url source-branch token dest-branch]
|
||||
ISource
|
||||
(load-source! [_this dir]
|
||||
(git/clone-repository! url source-branch dir token)
|
||||
dir)
|
||||
|
||||
IDestination
|
||||
(push-branch! [_this source-dir]
|
||||
(git/push-branch! {:source-dir source-dir
|
||||
:url url
|
||||
:source-branch source-branch
|
||||
:dest-branch dest-branch
|
||||
:token token})))
|
||||
|
||||
(defn get-source
|
||||
"Returns the source that we should actually use. A source implements the `ISource` protocol."
|
||||
[]
|
||||
(when (settings/git-sync-url)
|
||||
(->GitSource (settings/git-sync-url)
|
||||
(settings/git-sync-import-branch)
|
||||
(settings/git-sync-token)
|
||||
(settings/git-sync-export-branch))))
|
||||
@@ -32,7 +32,10 @@
|
||||
(into serdes.models/data-model)
|
||||
|
||||
(not (:no-settings opts))
|
||||
(conj "Setting")))
|
||||
(conj "Setting")
|
||||
|
||||
(not (:no-transforms opts))
|
||||
(conj "Transform" "TransformTag" "TransformJob")))
|
||||
|
||||
(defn targets-of-type
|
||||
"Returns target seq filtered on given model name"
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
|
||||
(declare load-one!)
|
||||
|
||||
(defn- filter-by-type-whitelist
|
||||
"Filter a list of entity paths, keeping only those whose model type is in the whitelist."
|
||||
[entity-paths type-whitelist]
|
||||
(filter (fn [path]
|
||||
(contains? type-whitelist (-> path peek :model)))
|
||||
entity-paths))
|
||||
|
||||
(def ^:private model->circular-dependency-keys
|
||||
"Sometimes models have circular dependencies. For example, a card for a Dashboard Question has a `dashboard_id`
|
||||
pointing to the dashboard it's in. But when we try to load that dashboard, we'll create all its dashcards, and one
|
||||
@@ -189,3 +196,41 @@
|
||||
;; ideally, we would delay the indexing somehow, or only reindex what we've loaded.
|
||||
;; while we're figuring that out, here's a crude stopgap.
|
||||
(search/reindex! {:async? false})))
|
||||
|
||||
(defn load-selective!
|
||||
"Loads entities from an ingestion source, but only those whose types are in the whitelist.
|
||||
|
||||
Parameters:
|
||||
- ingestion: Any Ingestable instance (e.g., YamlIngestion)
|
||||
- type-whitelist: Set of model names to load (e.g., #{\"Transform\"})
|
||||
- opts: Options map (same as load-metabase!)"
|
||||
[ingestion type-whitelist & {:keys [backfill? continue-on-error]
|
||||
:or {backfill? true
|
||||
continue-on-error false}}]
|
||||
(u/prog1
|
||||
(t2/with-transaction [_tx]
|
||||
(when backfill?
|
||||
(serdes.backfill/backfill-ids!))
|
||||
|
||||
(let [all-contents (serdes.ingest/ingest-list ingestion)
|
||||
;; Filter to only load whitelisted types
|
||||
filtered-contents (filter-by-type-whitelist all-contents type-whitelist)
|
||||
ctx {:expanding #{}
|
||||
:seen #{}
|
||||
:circular #{}
|
||||
:ingestion ingestion
|
||||
:from-ids (m/index-by :id filtered-contents)
|
||||
:errors []}]
|
||||
(log/infof "Starting selective deserialization for types %s, loading %s of %s total documents"
|
||||
type-whitelist (count filtered-contents) (count all-contents))
|
||||
(reduce (fn [ctx item]
|
||||
(try
|
||||
(load-one! ctx item)
|
||||
(catch Exception e
|
||||
(when-not continue-on-error
|
||||
(throw e))
|
||||
(log/warnf (u/strip-error e "Skipping deserialization error"))
|
||||
(update ctx :errors conj e))))
|
||||
ctx
|
||||
filtered-contents)))
|
||||
(search/reindex! {:async? false})))
|
||||
|
||||
@@ -26,7 +26,12 @@
|
||||
content
|
||||
["FieldValues"
|
||||
"Metabot"
|
||||
"Setting"]))
|
||||
"Setting"
|
||||
"Transform"
|
||||
"TransformTag"
|
||||
"TransformJob"
|
||||
"TransformJobTransformTag"
|
||||
"TransformTransformTag"]))
|
||||
|
||||
(def inlined-models
|
||||
"An additional list of models which are inlined into parent entities for serialization.
|
||||
@@ -92,14 +97,9 @@
|
||||
"Secret"
|
||||
"Session"
|
||||
"TaskHistory"
|
||||
"Transform"
|
||||
"TransformJob"
|
||||
"TransformJobRun"
|
||||
"TransformJobTransformTag"
|
||||
"TransformRun"
|
||||
"TransformRunCancelation"
|
||||
"TransformTag"
|
||||
"TransformTransformTag"
|
||||
"Undo"
|
||||
"User"
|
||||
"UserKeyValue"
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
[_route-params
|
||||
_query-params]
|
||||
(api/check-superuser)
|
||||
(t2/hydrate (t2/select :model/Transform) :last_run :transform_tag_ids))
|
||||
(t2/hydrate (t2/select :model/Transform) :last_run :transform_tag_ids :can_write))
|
||||
|
||||
(api.macros/defendpoint :post "/"
|
||||
"Create a new transform."
|
||||
@@ -113,7 +113,7 @@
|
||||
database-id (source-database-id transform)
|
||||
target-table (transforms.util/target-table database-id target :active true)]
|
||||
(-> transform
|
||||
(t2/hydrate :last_run :transform_tag_ids)
|
||||
(t2/hydrate :last_run :transform_tag_ids :can_write)
|
||||
(assoc :table target-table))))
|
||||
|
||||
(api.macros/defendpoint :get "/run"
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
[clojure.set :as set]
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.transforms.models.transform-run :as transform-run]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.models.serialization :as serdes]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
@@ -11,7 +13,7 @@
|
||||
|
||||
(methodical/defmethod t2/table-name :model/Transform [_model] :transform)
|
||||
|
||||
(doseq [trait [:metabase/model :hook/entity-id :hook/timestamped?]]
|
||||
(doseq [trait [:metabase/model :hook/entity-id :hook/timestamped? :hook/git-sync-protected :hook/branchable]]
|
||||
(derive :model/Transform trait))
|
||||
|
||||
(t2/deftransforms :model/Transform
|
||||
@@ -109,3 +111,61 @@
|
||||
{:transform_id transform-id
|
||||
:tag_id tag-id
|
||||
:position (get new-positions tag-id)})))))))
|
||||
|
||||
;;; ------------------------------------------------- Serialization --------------------------------------------------
|
||||
|
||||
(defmethod serdes/hash-fields :model/Transform
|
||||
[_transform]
|
||||
[:name :created_at])
|
||||
|
||||
(defn- extract-database-ids-from-target
|
||||
"Extract database IDs from target configuration."
|
||||
[target]
|
||||
(when (map? target)
|
||||
(cond
|
||||
;; Direct database reference
|
||||
(:database target) #{(:database target)}
|
||||
;; Nested database references
|
||||
(:databases target) (set (:databases target))
|
||||
;; Other potential patterns - extend as needed
|
||||
:else #{})))
|
||||
|
||||
(defn- transform-target-export
|
||||
"Transform target JSON for export, replacing database IDs with portable names."
|
||||
[target]
|
||||
(when target
|
||||
(let [export-db-fk (:export (serdes/fk :model/Database :name))]
|
||||
(cond-> target
|
||||
(:database target) (update :database export-db-fk)
|
||||
(:databases target) (update :databases #(mapv export-db-fk %))))))
|
||||
|
||||
(defn- transform-target-import
|
||||
"Transform target JSON for import, replacing portable names with database IDs."
|
||||
[target]
|
||||
(when target
|
||||
(let [import-db-fk (:import (serdes/fk :model/Database :name))]
|
||||
(cond-> target
|
||||
(:database target) (update :database import-db-fk)
|
||||
(:databases target) (update :databases #(mapv import-db-fk %))))))
|
||||
|
||||
(defmethod serdes/make-spec "Transform"
|
||||
[_model-name opts]
|
||||
{:copy [:name :description :entity_id]
|
||||
:skip [:synced_to_source_of_truth]
|
||||
:transform
|
||||
{:created_at (serdes/date)
|
||||
:updated_at (serdes/date)
|
||||
:source {:export serdes/export-mbql :import serdes/import-mbql}
|
||||
:target {:export transform-target-export
|
||||
:import transform-target-import}
|
||||
:tag_associations (serdes/nested :model/TransformTransformTag :transform_id opts)}})
|
||||
|
||||
(defmethod serdes/dependencies "Transform"
|
||||
[{:keys [source target]}]
|
||||
(set (serdes/mbql-deps source)))
|
||||
|
||||
(defmethod serdes/descendants "Transform"
|
||||
[_model-name id]
|
||||
;; Include the junction table records that link this transform to its tags
|
||||
(into {} (for [junction-id (t2/select-pks-set :model/TransformTransformTag :transform_id id)]
|
||||
{["TransformTransformTag" junction-id] {"Transform" id}})))
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.transforms.models.job-run :as transforms.job-run]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.models.serialization :as serdes]
|
||||
[metabase.util.i18n :as i18n]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
@@ -122,3 +123,28 @@
|
||||
(t2/changes job)) ;; user edits
|
||||
(update :name str) ;; convert deferred to strings
|
||||
(update :description str))))
|
||||
|
||||
;;; ------------------------------------------------- Serialization --------------------------------------------------
|
||||
|
||||
(defmethod serdes/hash-fields :model/TransformJob
|
||||
[_transform-job]
|
||||
[:name :created_at])
|
||||
|
||||
(defmethod serdes/make-spec "TransformJob"
|
||||
[_model-name opts]
|
||||
{:copy [:name :description :schedule :entity_id :built_in_type]
|
||||
:skip []
|
||||
:transform {:created_at (serdes/date)
|
||||
:updated_at (serdes/date)
|
||||
:tag_associations (serdes/nested :model/TransformJobTransformTag :job_id opts)}})
|
||||
|
||||
(defmethod serdes/dependencies "TransformJob"
|
||||
[_ingested]
|
||||
;; No direct dependencies - TransformTags will be loaded via the junction table dependencies
|
||||
#{})
|
||||
|
||||
(defmethod serdes/descendants "TransformJob"
|
||||
[_model-name id]
|
||||
;; Include the junction table records that link this job to its tags
|
||||
(into {} (for [junction-id (t2/select-pks-set :model/TransformJobTransformTag :job_id id)]
|
||||
{["TransformJobTransformTag" junction-id] {"TransformJob" id}})))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
(ns metabase-enterprise.transforms.models.transform-job-transform-tag
|
||||
(:require
|
||||
[metabase.models.serialization :as serdes]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
@@ -10,3 +11,32 @@
|
||||
(doto :model/TransformJobTransformTag
|
||||
(derive :metabase/model)
|
||||
(derive :hook/entity-id))
|
||||
|
||||
;;; ------------------------------------------------- Serialization --------------------------------------------------
|
||||
|
||||
(defmethod serdes/hash-fields :model/TransformJobTransformTag
|
||||
[_junction]
|
||||
[:job_id :tag_id :position])
|
||||
|
||||
(defmethod serdes/generate-path "TransformJobTransformTag"
|
||||
[_ _junction]
|
||||
;; Junction tables are inlined into their parent TransformJob, so no independent path
|
||||
nil)
|
||||
|
||||
(defmethod serdes/make-spec "TransformJobTransformTag"
|
||||
[_model-name _opts]
|
||||
{:copy [:entity_id :position]
|
||||
:skip []
|
||||
:transform {:job_id (serdes/parent-ref)
|
||||
:tag_id (serdes/fk :model/TransformTag)}})
|
||||
|
||||
(defmethod serdes/dependencies "TransformJobTransformTag"
|
||||
[{:keys [tag_id]}]
|
||||
;; Depends on the TransformTag being loaded first
|
||||
(when tag_id
|
||||
#{[{:model "TransformTag" :id tag_id}]}))
|
||||
|
||||
(defmethod serdes/descendants "TransformJobTransformTag"
|
||||
[_model-name _id]
|
||||
;; Junction tables don't contain other entities
|
||||
{})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
(ns metabase-enterprise.transforms.models.transform-tag
|
||||
(:require
|
||||
[metabase.models.serialization :as serdes]
|
||||
[metabase.util.i18n :as i18n]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
@@ -41,3 +42,23 @@
|
||||
{:built_in_type nil} ;; never translate this again
|
||||
(t2/changes tag)) ;; include user edits
|
||||
(update :name str)))) ;; convert deferred to string
|
||||
;;; ------------------------------------------------- Serialization --------------------------------------------------
|
||||
|
||||
(defmethod serdes/hash-fields :model/TransformTag
|
||||
[_transform-tag]
|
||||
[:name :created_at])
|
||||
|
||||
(defmethod serdes/make-spec "TransformTag"
|
||||
[_model-name _opts]
|
||||
{:copy [:name :entity_id :built_in_type]
|
||||
:skip []
|
||||
:transform {:created_at (serdes/date)
|
||||
:updated_at (serdes/date)}})
|
||||
|
||||
(defmethod serdes/dependencies "TransformTag"
|
||||
[_ingested]
|
||||
#{})
|
||||
|
||||
(defmethod serdes/descendants "TransformTag"
|
||||
[_model-name _id]
|
||||
{})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
(ns metabase-enterprise.transforms.models.transform-transform-tag
|
||||
(:require
|
||||
[metabase.models.serialization :as serdes]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
@@ -10,3 +11,32 @@
|
||||
(doto :model/TransformTransformTag
|
||||
(derive :metabase/model)
|
||||
(derive :hook/entity-id))
|
||||
|
||||
;;; ------------------------------------------------- Serialization --------------------------------------------------
|
||||
|
||||
(defmethod serdes/hash-fields :model/TransformTransformTag
|
||||
[_junction]
|
||||
[:transform_id :tag_id :position])
|
||||
|
||||
(defmethod serdes/generate-path "TransformTransformTag"
|
||||
[_ _junction]
|
||||
;; Junction tables are inlined into their parent Transform, so no independent path
|
||||
nil)
|
||||
|
||||
(defmethod serdes/make-spec "TransformTransformTag"
|
||||
[_model-name _opts]
|
||||
{:copy [:entity_id :position]
|
||||
:skip []
|
||||
:transform {:transform_id (serdes/parent-ref)
|
||||
:tag_id (serdes/fk :model/TransformTag)}})
|
||||
|
||||
(defmethod serdes/dependencies "TransformTransformTag"
|
||||
[{:keys [tag_id]}]
|
||||
;; Depends on the TransformTag being loaded first
|
||||
(when tag_id
|
||||
#{[{:model "TransformTag" :id tag_id}]}))
|
||||
|
||||
(defmethod serdes/descendants "TransformTransformTag"
|
||||
[_model-name _id]
|
||||
;; Junction tables don't contain other entities
|
||||
{})
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
(ns metabase-enterprise.branching.api-test
|
||||
"Tests for /api/ee/branch/ endpoints"
|
||||
(:require
|
||||
[clojure.set :as set]
|
||||
[clojure.test :refer :all]
|
||||
[metabase.api.response :as api.response]
|
||||
[metabase.test :as mt]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
;;; ------------------------------------------------- Test Fixtures -------------------------------------------------
|
||||
|
||||
(defn- cleanup-test-branches!
|
||||
"Delete all test branches created during tests."
|
||||
[]
|
||||
(t2/delete! :model/Branch))
|
||||
|
||||
;;; ------------------------------------------------- GET /api/ee/branch/ Tests -------------------------------------------------
|
||||
|
||||
(deftest list-branches-test
|
||||
(testing "GET /api/ee/branch/"
|
||||
(mt/with-premium-features #{:branching}
|
||||
(mt/with-temp [:model/Branch {branch1-id :id} {:name "Main Branch"}
|
||||
:model/Branch _ {:name "Feature Branch" :parent_branch_id branch1-id}
|
||||
:model/Branch _ {:name "Another Root Branch"}]
|
||||
(testing "Lists all branches without filter"
|
||||
(let [response (->> (mt/user-http-request :rasta :get 200 "ee/branch")
|
||||
:data
|
||||
(map (juxt :name :parent_branch_id))
|
||||
set)]
|
||||
(is (set/subset? #{["Another Root Branch" nil]
|
||||
["Feature Branch" branch1-id]
|
||||
["Main Branch" nil]} response))))
|
||||
|
||||
(testing "Filters branches by parent_id"
|
||||
(let [response (mt/user-http-request :rasta :get 200 (str "ee/branch?parent_id=" branch1-id))]
|
||||
(is (= 1 (count (:data response))))
|
||||
(is (= "Feature Branch" (-> response :data first :name)))
|
||||
(is (= branch1-id (-> response :data first :parent_branch_id)))))
|
||||
|
||||
(testing "Returns empty list for non-existent parent_id"
|
||||
(let [response (mt/user-http-request :rasta :get 200 "ee/branch?parent_id=99999")]
|
||||
(is (= 0 (count (:data response))))))
|
||||
|
||||
(testing "Filters for root branches when parent_id is -1'"
|
||||
(let [response (->> (mt/user-http-request :rasta :get 200 "ee/branch?parent_id=-1")
|
||||
:data
|
||||
(map :name)
|
||||
set)]
|
||||
(is (set/subset? #{"Main Branch" "Another Root Branch"} response))
|
||||
(is (not (contains? response "Feature Branch")))))))))
|
||||
|
||||
(deftest list-branches-authentication-test
|
||||
(testing "GET /api/ee/branch/ requires authentication"
|
||||
(mt/with-premium-features #{:branching}
|
||||
(is (= (get api.response/response-unauthentic :body)
|
||||
(mt/client :get 401 "ee/branch"))))))
|
||||
|
||||
(deftest list-branches-premium-feature-test
|
||||
(testing "GET /api/ee/branch/ requires branching premium feature"
|
||||
(mt/with-premium-features #{}
|
||||
(mt/with-temp [:model/Branch _ {:name "Test Branch"}]
|
||||
;; Should return 402 Payment Required when premium feature is not enabled
|
||||
(mt/user-http-request :rasta :get 402 "ee/branch")))))
|
||||
|
||||
;;; ------------------------------------------------- GET /api/ee/branch/:id Tests -------------------------------------------------
|
||||
|
||||
(deftest get-branch-test
|
||||
(testing "GET /api/ee/branch/:id"
|
||||
(mt/with-premium-features #{:branching}
|
||||
(mt/with-temp [:model/Branch {branch-id :id} {:name "Test Branch"}]
|
||||
(testing "Returns branch by ID"
|
||||
(let [response (mt/user-http-request :rasta :get 200 (str "ee/branch/" branch-id))]
|
||||
(is (= branch-id (:id response)))
|
||||
(is (= "Test Branch" (:name response)))
|
||||
(is (= "test_branch" (:slug response)))
|
||||
(is (= (mt/user->id :rasta) (:creator_id response)))
|
||||
(is (nil? (:parent_branch_id response)))
|
||||
(is (contains? response :created_at))
|
||||
(is (contains? response :updated_at))))
|
||||
|
||||
(testing "Returns 404 for non-existent branch ID"
|
||||
(mt/user-http-request :rasta :get 404 "ee/branch/99999"))))))
|
||||
|
||||
(deftest get-branch-authentication-test
|
||||
(testing "GET /api/ee/branch/:id requires authentication"
|
||||
(mt/with-premium-features #{:branching}
|
||||
(is (= (get api.response/response-unauthentic :body)
|
||||
(mt/client :get 401 "ee/branch/1"))))))
|
||||
|
||||
(deftest get-branch-premium-feature-test
|
||||
(testing "GET /api/ee/branch/:id requires branching premium feature"
|
||||
(mt/with-premium-features #{}
|
||||
(mt/with-temp [:model/Branch {branch-id :id} {:name "Test Branch"}]
|
||||
;; Should return 402 Payment Required when premium feature is not enabled
|
||||
(mt/user-http-request :rasta :get 402 (str "ee/branch/" branch-id))))))
|
||||
|
||||
;;; ------------------------------------------------- POST /api/ee/branch/ Tests -------------------------------------------------
|
||||
|
||||
(deftest create-branch-test
|
||||
(testing "POST /api/ee/branch/"
|
||||
(mt/with-premium-features #{:branching}
|
||||
(mt/with-model-cleanup [:model/Branch]
|
||||
|
||||
(testing "Creates branch with valid data"
|
||||
(let [response (mt/user-http-request :rasta :post 200 "ee/branch"
|
||||
{:name "New Branch"
|
||||
:description "A test branch"})]
|
||||
(is (number? (:id response)))
|
||||
(is (= "New Branch" (:name response)))
|
||||
(is (= "new_branch" (:slug response)))
|
||||
(is (= (mt/user->id :rasta) (:creator_id response)))
|
||||
(is (nil? (:parent_branch_id response)))
|
||||
(is (contains? response :created_at))
|
||||
(is (contains? response :updated_at))))
|
||||
|
||||
(testing "Creates child branch with valid parent_branch_id"
|
||||
(mt/with-temp [:model/Branch {parent-id :id} {:name "Parent Branch"}]
|
||||
(let [response (mt/user-http-request :rasta :post 200 "ee/branch"
|
||||
{:name "Child Branch"
|
||||
:parent_branch_id parent-id})]
|
||||
(is (= parent-id (:parent_branch_id response)))
|
||||
(is (= "Child Branch" (:name response))))))
|
||||
|
||||
(testing "Creates branch without description (optional field)"
|
||||
(let [response (mt/user-http-request :rasta :post 200 "ee/branch"
|
||||
{:name "Minimal Branch"})]
|
||||
(is (= "Minimal Branch" (:name response)))
|
||||
(is (nil? (:description response)))))))))
|
||||
|
||||
(deftest create-branch-validation-test
|
||||
(testing "POST /api/ee/branch/ input validation"
|
||||
(mt/with-premium-features #{:branching}
|
||||
(mt/with-model-cleanup [:model/Branch]
|
||||
|
||||
(testing "Rejects request with missing name"
|
||||
(mt/user-http-request :rasta :post 400 "ee/branch" {}))
|
||||
|
||||
(testing "Rejects request with blank name"
|
||||
(mt/user-http-request :rasta :post 400 "ee/branch" {:name ""}))
|
||||
|
||||
(testing "Rejects request with invalid parent_branch_id"
|
||||
(mt/user-http-request :rasta :post 400 "ee/branch"
|
||||
{:name "Invalid Parent Branch"
|
||||
:parent_branch_id 99999}))
|
||||
|
||||
(testing "Rejects request with non-integer parent_branch_id"
|
||||
(mt/user-http-request :rasta :post 400 "ee/branch"
|
||||
{:name "Invalid Parent Type"
|
||||
:parent_branch_id "not-a-number"}))))))
|
||||
|
||||
(deftest create-branch-authentication-test
|
||||
(testing "POST /api/ee/branch/ requires authentication"
|
||||
(mt/with-premium-features #{:branching}
|
||||
(is (= (get api.response/response-unauthentic :body)
|
||||
(mt/client :post 401 "ee/branch" {:name "Test Branch"}))))))
|
||||
|
||||
(deftest create-branch-premium-feature-test
|
||||
(testing "POST /api/ee/branch/ requires branching premium feature"
|
||||
(mt/with-premium-features #{}
|
||||
;; Should return 402 Payment Required when premium feature is not enabled
|
||||
(mt/user-http-request :rasta :post 402 "ee/branch" {:name "Test Branch"}))))
|
||||
|
||||
;;; ------------------------------------------------- DELETE /api/ee/branch/:id Tests -------------------------------------------------
|
||||
|
||||
(deftest delete-branch-test
|
||||
(testing "DELETE /api/ee/branch/:id"
|
||||
(mt/with-premium-features #{:branching}
|
||||
(mt/with-model-cleanup [:model/Branch]
|
||||
|
||||
(testing "Deletes branch without children"
|
||||
(mt/with-temp [:model/Branch {branch-id :id} {:name "Branch to Delete"}]
|
||||
(mt/user-http-request :rasta :delete 204 (str "ee/branch/" branch-id))
|
||||
(is (nil? (t2/select-one :model/Branch :id branch-id)))))
|
||||
|
||||
(testing "Returns 404 when trying to delete non-existent branch"
|
||||
(mt/user-http-request :rasta :delete 404 "ee/branch/99999"))))))
|
||||
|
||||
(deftest delete-branch-with-children-test
|
||||
(testing "DELETE /api/ee/branch/:id prevents deletion of branch with children"
|
||||
(mt/with-premium-features #{:branching}
|
||||
(mt/with-model-cleanup [:model/Branch]
|
||||
(mt/with-temp [:model/Branch {parent-id :id} {:name "Parent Branch"}
|
||||
:model/Branch _child-branch {:name "Child Branch" :parent_branch_id parent-id}]
|
||||
|
||||
(testing "Returns 400 when trying to delete branch with children"
|
||||
(mt/user-http-request :rasta :delete 400 (str "ee/branch/" parent-id)))
|
||||
|
||||
(testing "Parent branch still exists after failed deletion attempt"
|
||||
(is (some? (t2/select-one :model/Branch :id parent-id)))))))))
|
||||
|
||||
(deftest delete-branch-authentication-test
|
||||
(testing "DELETE /api/ee/branch/:id requires authentication"
|
||||
(mt/with-premium-features #{:branching}
|
||||
(is (= (get api.response/response-unauthentic :body)
|
||||
(mt/client :delete 401 "ee/branch/1"))))))
|
||||
|
||||
(comment
|
||||
(deftest delete-branch-premium-feature-test
|
||||
(testing "DELETE /api/ee/branch/:id requires branching premium feature"
|
||||
(mt/with-premium-features #{}
|
||||
(mt/with-temp [:model/Branch {branch-id :id} {:name "Test Branch"}]
|
||||
;; Should return 402 Payment Required when premium feature is not enabled
|
||||
(mt/user-http-request :rasta :delete 402 (str "ee/branch/" branch-id)))))))
|
||||
|
||||
;;; ------------------------------------------------- Edge Cases and Error Handling -------------------------------------------------
|
||||
|
||||
(deftest branch-hierarchy-test
|
||||
(testing "Complex branch hierarchy operations"
|
||||
(mt/with-premium-features #{:branching}
|
||||
(mt/with-temp [:model/Branch {root-id :id} {:name "Root"}
|
||||
:model/Branch {level1-id :id} {:name "Level 1" :parent_branch_id root-id}
|
||||
:model/Branch {level2-id :id} {:name "Level 2" :parent_branch_id level1-id}]
|
||||
|
||||
(testing "Can delete leaf node (deepest branch)"
|
||||
(mt/user-http-request :rasta :delete 204 (str "ee/branch/" level2-id))
|
||||
(is (nil? (t2/select-one :model/Branch :id level2-id))))
|
||||
|
||||
(testing "Can delete middle node after removing children"
|
||||
(mt/user-http-request :rasta :delete 204 (str "ee/branch/" level1-id))
|
||||
(is (nil? (t2/select-one :model/Branch :id level1-id))))
|
||||
|
||||
(testing "Can delete root node after removing all children"
|
||||
(mt/user-http-request :rasta :delete 204 (str "ee/branch/" root-id))
|
||||
(is (nil? (t2/select-one :model/Branch :id root-id))))))))
|
||||
|
||||
(deftest parameter-validation-edge-cases-test
|
||||
(testing "Parameter validation edge cases"
|
||||
(mt/with-premium-features #{:branching}
|
||||
(mt/with-model-cleanup [:model/Branch]
|
||||
|
||||
(comment
|
||||
(testing "GET /api/ee/branch/ with invalid parent_id type"
|
||||
;; Should handle gracefully - invalid integer should return 400
|
||||
(mt/user-http-request :rasta :get 400 "ee/branch" {:parent_id "invalid"})))
|
||||
|
||||
(testing "GET /api/ee/branch/:id with invalid ID format"
|
||||
;; Should handle gracefully - invalid integer should return 400
|
||||
(mt/user-http-request :rasta :get 404 "ee/branch/invalid-id"))
|
||||
|
||||
(testing "POST /api/ee/branch/ with extra unexpected fields"
|
||||
;; Should ignore extra fields and create branch successfully
|
||||
(let [response (mt/user-http-request :rasta :post 200 "ee/branch"
|
||||
{:name "Test Branch"
|
||||
:unexpected_field "should be ignored"})]
|
||||
(is (= "Test Branch" (:name response)))
|
||||
(is (not (contains? response :unexpected_field)))))))))
|
||||
|
||||
(deftest concurrent-operations-test
|
||||
(testing "Concurrent branch operations"
|
||||
(mt/with-premium-features #{:branching}
|
||||
(mt/with-temp [:model/Branch {parent-id :id} {:name "Parent"}
|
||||
:model/Branch {child-id :id} {:name "Child" :parent_branch_id parent-id}]
|
||||
|
||||
(testing "Multiple child branch creation for same parent"
|
||||
(let [responses (doall
|
||||
(pmap (fn [i]
|
||||
(mt/user-http-request :rasta :post 200 "ee/branch"
|
||||
{:name (str "Concurrent Child " i)
|
||||
:parent_branch_id parent-id}))
|
||||
(range 5)))]
|
||||
(is (= 5 (count responses)))
|
||||
(is (every? #(= parent-id (:parent_branch_id %)) responses))
|
||||
(is (= 5 (count (distinct (map :id responses)))))))
|
||||
|
||||
(testing "Cannot delete parent while children exist (race condition protection)"
|
||||
(mt/user-http-request :rasta :delete 400 (str "ee/branch/" parent-id)))))))
|
||||
@@ -0,0 +1,85 @@
|
||||
(ns metabase-enterprise.branching.hook-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase.branching.core :as branching]
|
||||
[metabase.test :as mt]
|
||||
[metabase.util.string :as string]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(deftest insert-cards
|
||||
(let [branch1-name (str "branch1-" (string/random-string 5))
|
||||
branch2-name (str "branch2-" (string/random-string 5))
|
||||
branch1-id (t2/insert-returning-pk! :model/Branch {:name branch1-name})
|
||||
_branch2-id (t2/insert-returning-pk! :model/Branch {:name branch2-name})]
|
||||
(testing "No branch set"
|
||||
(let [no-branch-name (str "no-branch-" (string/random-string 5))
|
||||
no-branch-id (t2/insert-returning-pk! :model/Card {:name no-branch-name
|
||||
:display "table"
|
||||
:dataset_query "{}"
|
||||
:visualization_settings "{}"
|
||||
:creator_id (mt/user->id :rasta)
|
||||
:database_id 1})]
|
||||
(is (= no-branch-name (:name (t2/select-one (t2/table-name :model/Card) no-branch-id))))
|
||||
(is (false? (t2/exists? :model/BranchModelMapping :original_id no-branch-id)))
|
||||
|
||||
(testing "Can see the no-branch from main"
|
||||
(is (= no-branch-id (:id (t2/select-one :model/Card :name no-branch-name)))))
|
||||
(testing "Can see the no-branch from another branch"
|
||||
(branching/with-current-branch-var branch1-name
|
||||
(is (= no-branch-id (:id (t2/select-one :model/Card :name no-branch-name))))))
|
||||
|
||||
(testing "Inserting into a branch"
|
||||
(branching/with-current-branch-var branch1-name
|
||||
(let [card1-id (t2/insert-returning-pk! :model/Card {:name "Branch1 Card"
|
||||
:display "table"
|
||||
:dataset_query "{}"
|
||||
:visualization_settings "{}"
|
||||
:creator_id (mt/user->id :rasta)
|
||||
:database_id 1})]
|
||||
(is (= "Branch1 Card" (:name (t2/select-one (t2/table-name :model/Card) card1-id))))
|
||||
(is (= [[branch1-id card1-id]] (t2/select-fn-vec (juxt :branch_id :original_id) :model/BranchModelMapping :original_id card1-id)))
|
||||
(testing "Can select it out while in the branch"
|
||||
(is (= no-branch-id (:id (t2/select-one :model/Card :name no-branch-name)))))
|
||||
(testing "Can't select it from a different branch"
|
||||
(branching/with-current-branch-var branch2-name
|
||||
(is (= 0 (count (t2/select :model/Card :id card1-id)))))))))))))
|
||||
|
||||
(deftest update-cares
|
||||
(let [branch1-name (str "branch1-" (string/random-string 5))
|
||||
branch2-name (str "branch2-" (string/random-string 5))
|
||||
branch1-id (t2/insert-returning-pk! :model/Branch {:name branch1-name})
|
||||
_branch2-id (t2/insert-returning-pk! :model/Branch {:name branch2-name})
|
||||
base-card-id (t2/insert-returning-pk! :model/Card {:name "Base Card Version"
|
||||
:display "table"
|
||||
:dataset_query "{}"
|
||||
:visualization_settings "{}"
|
||||
:creator_id (mt/user->id :rasta)
|
||||
:database_id 1})]
|
||||
(testing "No branch set updating"
|
||||
(t2/update! (t2/table-name :model/Card) base-card-id {:name "Updated Base Card Version"})
|
||||
|
||||
(is (= "Updated Base Card Version" (:name (t2/select-one (t2/table-name :model/Card) base-card-id))))
|
||||
(is (false? (t2/exists? :model/BranchModelMapping :original_id base-card-id)))
|
||||
|
||||
(testing "Can see the change on another branch which has no changes for it"
|
||||
(branching/with-current-branch-var branch1-name
|
||||
(is (= "Updated Base Card Version" (:name (t2/select-one :model/Card :id base-card-id))))))
|
||||
|
||||
(testing "Updating in a branch by pk"
|
||||
(branching/with-current-branch-var branch1-name
|
||||
(t2/update! :model/Card base-card-id {:name "Renamed in Branch 1"})
|
||||
(is (= "Renamed in Branch 1" (:name (t2/select-one :model/Card :id base-card-id))))
|
||||
(is (= [[branch1-id base-card-id]] (t2/select-fn-vec (juxt :branch_id :original_id) :model/BranchModelMapping :original_id base-card-id)))
|
||||
|
||||
(testing "main branch doesn't see the change"
|
||||
(branching/with-current-branch-var nil
|
||||
(is (= "Updated Base Card Version" (:name (t2/select-one :model/Card :id base-card-id))))))
|
||||
(testing "other branch doesn't see the change"
|
||||
(branching/with-current-branch-var branch2-name
|
||||
(is (= "Updated Base Card Version" (:name (t2/select-one :model/Card :id base-card-id))))))
|
||||
|
||||
(testing "Can update by non-PK"
|
||||
(t2/update! :model/Card :name "Renamed in Branch 1" {:name "Renamed by name in Branch 1"})
|
||||
(is (= "Renamed by name in Branch 1" (:name (t2/select-one :model/Card :id base-card-id))))
|
||||
(branching/with-current-branch-var nil
|
||||
(is (= "Updated Base Card Version" (:name (t2/select-one :model/Card :id base-card-id)))))))))))
|
||||
79
enterprise/frontend/src/metabase-enterprise/api/git-sync.ts
Normal file
79
enterprise/frontend/src/metabase-enterprise/api/git-sync.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type {
|
||||
CreateBranchRequest,
|
||||
GitBranch,
|
||||
GitDiff,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { EnterpriseApi } from "./api";
|
||||
import { idTag, invalidateTags } from "./tags";
|
||||
|
||||
export const gitSyncApi = EnterpriseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
importGit: builder.mutation({
|
||||
query: () => ({
|
||||
method: "POST",
|
||||
url: "/api/ee/git-source-of-truth/import",
|
||||
}),
|
||||
}),
|
||||
exportGit: builder.mutation({
|
||||
query: () => ({
|
||||
method: "POST",
|
||||
url: "/api/ee/git-source-of-truth/export",
|
||||
}),
|
||||
}),
|
||||
listGitBranches: builder.query<GitBranch[], void>({
|
||||
query: () => ({
|
||||
url: `/api/ee/branch/`,
|
||||
method: "GET",
|
||||
}),
|
||||
transformResponse: (response: { data: GitBranch[] }) => response.data,
|
||||
providesTags: ["git-branch"],
|
||||
}),
|
||||
|
||||
getGitBranch: builder.query<GitBranch, number>({
|
||||
query: (id) => ({
|
||||
url: `/api/ee/branch/${id}`,
|
||||
method: "GET",
|
||||
}),
|
||||
providesTags: (result, error, id) =>
|
||||
invalidateTags(error, [idTag("git-branch", id)]),
|
||||
}),
|
||||
|
||||
createGitBranch: builder.mutation<GitBranch, CreateBranchRequest>({
|
||||
query: (body) => ({
|
||||
url: `/api/ee/branch/`,
|
||||
method: "POST",
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ["git-branch"],
|
||||
}),
|
||||
|
||||
deleteGitBranch: builder.mutation<void, number>({
|
||||
query: (id) => ({
|
||||
url: `/api/ee/branch/${id}`,
|
||||
method: "DELETE",
|
||||
}),
|
||||
invalidatesTags: ["git-branch"],
|
||||
}),
|
||||
|
||||
getGitDiff: builder.query<GitDiff[], { branchId: number }>({
|
||||
query: ({ branchId }) => ({
|
||||
url: `/api/ee/branch/${branchId}/diff`,
|
||||
method: "GET",
|
||||
}),
|
||||
transformResponse: (response: { data: GitDiff[] }) => response.data,
|
||||
providesTags: (result, error, { branchId }) =>
|
||||
invalidateTags(error, [idTag("git-diff", branchId)]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useImportGitMutation,
|
||||
useExportGitMutation,
|
||||
useListGitBranchesQuery,
|
||||
useGetGitBranchQuery,
|
||||
useCreateGitBranchMutation,
|
||||
useDeleteGitBranchMutation,
|
||||
useGetGitDiffQuery,
|
||||
} = gitSyncApi;
|
||||
@@ -6,6 +6,7 @@ export * from "./billing-info";
|
||||
export * from "./content-translation";
|
||||
export * from "./db-routing";
|
||||
export * from "./gdrive";
|
||||
export * from "./git-sync";
|
||||
export * from "./metabot";
|
||||
export * from "./document";
|
||||
export * from "./saml";
|
||||
|
||||
@@ -20,6 +20,8 @@ export const ENTERPRISE_TAG_TYPES = [
|
||||
"transform-tag",
|
||||
"transform-job",
|
||||
"transform-run",
|
||||
"git-branch",
|
||||
"git-diff",
|
||||
] as const;
|
||||
|
||||
export type EnterpriseTagType = (typeof ENTERPRISE_TAG_TYPES)[number];
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import {
|
||||
SettingsPageWrapper,
|
||||
SettingsSection,
|
||||
} from "metabase/admin/components/SettingsSection";
|
||||
import { Table } from "metabase/common/components/Table";
|
||||
import { Button } from "metabase/ui";
|
||||
|
||||
export const GitChangeList = () => {
|
||||
return (
|
||||
<SettingsPageWrapper title="Git changes">
|
||||
<SettingsSection title="Pending changes">
|
||||
<Table
|
||||
rows={[
|
||||
{ id: 1, change: "Added new dashboard", author: "Alice" },
|
||||
{ id: 2, change: "Updated metric definition", author: "Bob" },
|
||||
{ id: 3, change: "Removed segment", author: "Charlie" },
|
||||
]}
|
||||
columns={[
|
||||
{ key: "author", name: "Author" },
|
||||
{ key: "change", name: "Change" },
|
||||
{ key: "actions", name: "" },
|
||||
]}
|
||||
rowRenderer={(row) => (
|
||||
<tr>
|
||||
<td style={{ padding: "8px 16px" }}>{row.author}</td>
|
||||
<td style={{ padding: "8px 16px" }}>{row.change}</td>
|
||||
<td style={{ padding: "8px 16px" }}>
|
||||
<Button variant="filled" color="danger" size="compact-sm">
|
||||
{t`Reject`}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</SettingsPageWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import {
|
||||
SettingsPageWrapper,
|
||||
SettingsSection,
|
||||
} from "metabase/admin/components/SettingsSection";
|
||||
import { AdminSettingInput } from "metabase/admin/settings/components/widgets/AdminSettingInput";
|
||||
import ActionButton from "metabase/common/components/ActionButton";
|
||||
import { useSetting } from "metabase/common/hooks";
|
||||
import { Box, Flex, Group, Icon, Stack, Text } from "metabase/ui";
|
||||
import {
|
||||
useExportGitMutation,
|
||||
useImportGitMutation,
|
||||
} from "metabase-enterprise/api";
|
||||
|
||||
export const GitSyncSettings = () => {
|
||||
const [importGit, { isLoading: isImporting }] = useImportGitMutation();
|
||||
const [exportGit, { isLoading: isExporting }] = useExportGitMutation();
|
||||
|
||||
const isLoading = isImporting || isExporting;
|
||||
|
||||
return (
|
||||
<SettingsPageWrapper title={t`Git sync settings`}>
|
||||
<SettingsSection
|
||||
title={t`Configure git`}
|
||||
description={
|
||||
<Stack gap="xs">
|
||||
<Text>
|
||||
{/* eslint-disable-next-line */}
|
||||
{t`Enable Git sync to back up and version control your Metabase library.`}
|
||||
</Text>
|
||||
<GitSyncStatus />
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<AdminSettingInput
|
||||
name="git-sync-url"
|
||||
title={t`Git Url`}
|
||||
inputType="text"
|
||||
/>
|
||||
<AdminSettingInput
|
||||
name="git-sync-token"
|
||||
title={t`Github PAT`}
|
||||
inputType="password"
|
||||
/>
|
||||
<AdminSettingInput
|
||||
name="git-sync-default-branch"
|
||||
title={t`Default branch`}
|
||||
inputType="text"
|
||||
/>
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
title={t`Sync control`}
|
||||
description={t`Manually trigger a git sync to push any local changes to the remote repository and pull down any changes from the remote repository.`}
|
||||
>
|
||||
<AdminSettingInput
|
||||
name="git-sync-read-only"
|
||||
// eslint-disable-next-line
|
||||
description={t`Make this instance read only, preventing any changes except when pulled from git.`}
|
||||
title={t`Read only mode`}
|
||||
inputType="boolean"
|
||||
/>
|
||||
<Flex gap="md" align="end">
|
||||
<AdminSettingInput
|
||||
name="git-sync-import-branch"
|
||||
// eslint-disable-next-line
|
||||
description={t`Metabase will pull in all content from this branch`}
|
||||
title={t`Import branch`}
|
||||
inputType="text"
|
||||
w="20rem"
|
||||
/>
|
||||
<ActionButton
|
||||
primary
|
||||
actionFn={() => importGit({}).unwrap()}
|
||||
variant="filled"
|
||||
failedText={t`Sync failed`}
|
||||
activeText={t`Syncing...`}
|
||||
successText={t`Synced`}
|
||||
useLoadingSpinner
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Group align="center" gap="sm">
|
||||
<Icon name="download" />
|
||||
{t`Import`}
|
||||
</Group>
|
||||
</ActionButton>
|
||||
</Flex>
|
||||
<Flex gap="md" align="end">
|
||||
<AdminSettingInput
|
||||
name="git-sync-export-branch"
|
||||
// eslint-disable-next-line
|
||||
description={t`Metabase will save all content to this branch`}
|
||||
title={t`Export branch`}
|
||||
inputType="text"
|
||||
w="20rem"
|
||||
/>
|
||||
<ActionButton
|
||||
primary
|
||||
actionFn={() => exportGit({}).unwrap()}
|
||||
variant="filled"
|
||||
failedText={t`Sync failed`}
|
||||
activeText={t`Syncing...`}
|
||||
successText={t`Synced`}
|
||||
useLoadingSpinner
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Group align="center" gap="sm">
|
||||
<Icon name="upload" />
|
||||
{t`Export`}
|
||||
</Group>
|
||||
</ActionButton>
|
||||
</Flex>
|
||||
</SettingsSection>
|
||||
</SettingsPageWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const GitSyncStatus = () => {
|
||||
const syncStatus = useSetting("git-sync-configured");
|
||||
|
||||
const color = syncStatus ? "success" : "error";
|
||||
const message = syncStatus
|
||||
? t`Git sync is configured.`
|
||||
: t`Git sync is not configured.`;
|
||||
|
||||
return (
|
||||
<Flex align={"center"}>
|
||||
<Box bdrs="50%" h="14" w="14" mr="sm" bg={color} />
|
||||
<Text c="text-medium">{message}</Text>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { SettingsNavItem } from "metabase/admin/settings/components/SettingsNav";
|
||||
|
||||
export const LibraryNav = () => {
|
||||
return (
|
||||
<SettingsNavItem path="library" label={t`Library`} icon="folder">
|
||||
<SettingsNavItem path="library/sync" label={t`Sync`} />
|
||||
<SettingsNavItem path="library/changes" label={t`Changes`} />
|
||||
</SettingsNavItem>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { getCurrentUser } from "metabase/admin/datamodel/selectors";
|
||||
import { Badge } from "metabase/common/components/Badge";
|
||||
import { useUserSetting } from "metabase/common/hooks";
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Combobox,
|
||||
Divider,
|
||||
Group,
|
||||
Icon,
|
||||
Loader,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
useCombobox,
|
||||
} from "metabase/ui";
|
||||
import {
|
||||
useCreateGitBranchMutation,
|
||||
useDeleteGitBranchMutation,
|
||||
useListGitBranchesQuery,
|
||||
} from "metabase-enterprise/api/git-sync";
|
||||
import type { GitBranch } from "metabase-types/api";
|
||||
|
||||
interface SelectBranchProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const SelectBranch = ({ disabled = false }: SelectBranchProps) => {
|
||||
const [currentBranch, setCurrentBranch] = useUserSetting("git-branch", {
|
||||
shouldDebounce: false,
|
||||
});
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const currentUser = useSelector(getCurrentUser);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: branches = [], isLoading } = useListGitBranchesQuery();
|
||||
|
||||
const defaultBranch = branches.find((b) => b.name === "main");
|
||||
const displayBranch = currentBranch || defaultBranch?.name;
|
||||
const [createBranch, { isLoading: isCreatingBranch }] =
|
||||
useCreateGitBranchMutation();
|
||||
const [deleteBranch, { isLoading: isDeletingBranch }] =
|
||||
useDeleteGitBranchMutation();
|
||||
|
||||
const filteredBranches = useMemo(() => {
|
||||
const availableBranches = branches.filter(
|
||||
(branch) => branch.name !== displayBranch,
|
||||
);
|
||||
|
||||
if (!searchValue) {
|
||||
return availableBranches;
|
||||
}
|
||||
const search = searchValue.toLowerCase();
|
||||
return availableBranches.filter((branch) =>
|
||||
branch.name.toLowerCase().includes(search),
|
||||
);
|
||||
}, [branches, searchValue, displayBranch]);
|
||||
|
||||
const isExactMatch = useMemo(
|
||||
() =>
|
||||
filteredBranches.some(
|
||||
(b) => b.name.toLowerCase() === searchValue.toLowerCase(),
|
||||
),
|
||||
[filteredBranches, searchValue],
|
||||
);
|
||||
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => {
|
||||
combobox.resetSelectedOption();
|
||||
setSearchValue("");
|
||||
},
|
||||
onDropdownOpen: () => {
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 0);
|
||||
},
|
||||
});
|
||||
|
||||
const handleBranchSelect = useCallback(
|
||||
(branch: GitBranch) => {
|
||||
if (branch.name === displayBranch) {
|
||||
combobox.closeDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentBranch(branch.name);
|
||||
combobox.closeDropdown();
|
||||
setSearchValue("");
|
||||
|
||||
// Force page reload to clear all cached data
|
||||
window.location.reload();
|
||||
},
|
||||
[displayBranch, setCurrentBranch, combobox],
|
||||
);
|
||||
|
||||
const handleCreateAndSwitch = useCallback(
|
||||
async (branchName: string) => {
|
||||
try {
|
||||
const result = await createBranch({
|
||||
name: branchName.trim(),
|
||||
}).unwrap();
|
||||
|
||||
handleBranchSelect(result);
|
||||
} catch (error) {
|
||||
console.error("Failed to create branch:", error);
|
||||
}
|
||||
},
|
||||
[createBranch, handleBranchSelect],
|
||||
);
|
||||
|
||||
const handleDeleteBranch = useCallback(
|
||||
async (event: React.MouseEvent, branch: GitBranch) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (branch.name === displayBranch) {
|
||||
const defaultBranch = branches.find((b) => b.name === "main");
|
||||
if (defaultBranch) {
|
||||
setCurrentBranch(defaultBranch.name);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteBranch(branch.id).unwrap();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete branch:", error);
|
||||
}
|
||||
},
|
||||
[branches, deleteBranch, displayBranch, setCurrentBranch],
|
||||
);
|
||||
|
||||
return (
|
||||
<Combobox store={combobox} withinPortal position="bottom-end">
|
||||
<Combobox.Target>
|
||||
<UnstyledButton
|
||||
onClick={() => combobox.toggleDropdown()}
|
||||
disabled={disabled || isCreatingBranch}
|
||||
style={{ minWidth: 0 }}
|
||||
>
|
||||
<Badge
|
||||
icon={{ name: "git_branch", size: 14 }}
|
||||
activeColor="text-white"
|
||||
inactiveColor="text-white"
|
||||
isSingleLine
|
||||
>
|
||||
{displayBranch}
|
||||
{(isLoading || isCreatingBranch || isDeletingBranch) && (
|
||||
<Loader size="xs" ml="xs" />
|
||||
)}
|
||||
</Badge>
|
||||
</UnstyledButton>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown style={{ minWidth: 320 }}>
|
||||
<Combobox.EventsTarget>
|
||||
<Stack gap={0}>
|
||||
<Box p="sm">
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
placeholder={t`Find or create branch`}
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.currentTarget.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{(filteredBranches.length > 0 || searchValue) && <Divider />}
|
||||
|
||||
<ScrollArea.Autosize mah={300}>
|
||||
<Stack gap={0} p="sm">
|
||||
{defaultBranch && filteredBranches.includes(defaultBranch) && (
|
||||
<>
|
||||
<Combobox.Option
|
||||
value={defaultBranch.name}
|
||||
onClick={() => handleBranchSelect(defaultBranch)}
|
||||
py="xs"
|
||||
>
|
||||
<Group justify="space-between" w="100%">
|
||||
<Text>{defaultBranch.name}</Text>
|
||||
</Group>
|
||||
</Combobox.Option>
|
||||
{filteredBranches.filter((b) => b !== defaultBranch)
|
||||
.length > 0 && <Divider my="xs" />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{filteredBranches
|
||||
.filter((b) => b !== defaultBranch)
|
||||
.map((branch) => {
|
||||
const isAuthor = currentUser?.id === branch.creator_id;
|
||||
const canDelete = isAuthor && branch.name !== displayBranch;
|
||||
|
||||
return (
|
||||
<Combobox.Option
|
||||
key={branch.id}
|
||||
value={branch.name}
|
||||
onClick={() => handleBranchSelect(branch)}
|
||||
py="xs"
|
||||
>
|
||||
<Group justify="space-between" w="100%">
|
||||
<Text>{branch.name}</Text>
|
||||
{canDelete && (
|
||||
<Tooltip label={t`Delete branch`}>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={(e) => handleDeleteBranch(e, branch)}
|
||||
disabled={isDeletingBranch}
|
||||
>
|
||||
<Icon name="close" size={12} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Combobox.Option>
|
||||
);
|
||||
})}
|
||||
|
||||
{searchValue && !isExactMatch && (
|
||||
<Combobox.Option
|
||||
value="create-new"
|
||||
onClick={() => handleCreateAndSwitch(searchValue)}
|
||||
disabled={isCreatingBranch}
|
||||
py="xs"
|
||||
>
|
||||
<Text c="brand">{t`Create "${searchValue}" off "main"`}</Text>
|
||||
</Combobox.Option>
|
||||
)}
|
||||
|
||||
{!searchValue && filteredBranches.length === 0 && (
|
||||
<Box p="sm">
|
||||
<Text size="md" c="text-medium" ta="center">
|
||||
{branches.length === 0
|
||||
? t`Type a name to create your first branch`
|
||||
: t`No branches found`}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
</Combobox.EventsTarget>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useUserSetting } from "metabase/common/hooks";
|
||||
import { Icon, Tooltip, UnstyledButton } from "metabase/ui";
|
||||
import { useListGitBranchesQuery } from "metabase-enterprise/api/git-sync";
|
||||
|
||||
import { ViewChangesModal } from "./ViewChangesModal";
|
||||
|
||||
export const ViewChangesButton = () => {
|
||||
const [currentBranch] = useUserSetting("git-branch", {
|
||||
shouldDebounce: false,
|
||||
});
|
||||
const [modalOpened, setModalOpened] = useState(false);
|
||||
const { data: branches = [] } = useListGitBranchesQuery();
|
||||
|
||||
const defaultBranch = branches.find((b) => !b.parent_branch_id);
|
||||
|
||||
const displayBranch = currentBranch || defaultBranch?.name;
|
||||
|
||||
if (displayBranch === defaultBranch?.name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip label={t`Compare changes`}>
|
||||
<UnstyledButton
|
||||
onClick={() => setModalOpened(true)}
|
||||
style={{ minWidth: 0 }}
|
||||
>
|
||||
<Icon
|
||||
name="git_compare"
|
||||
size={16}
|
||||
color="text-dark"
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
</UnstyledButton>
|
||||
</Tooltip>
|
||||
|
||||
<ViewChangesModal
|
||||
opened={modalOpened}
|
||||
onClose={() => setModalOpened(false)}
|
||||
currentBranch={displayBranch}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,254 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Center,
|
||||
Flex,
|
||||
Group,
|
||||
Icon,
|
||||
Loader,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from "metabase/ui";
|
||||
import {
|
||||
useGetGitDiffQuery,
|
||||
useListGitBranchesQuery,
|
||||
} from "metabase-enterprise/api/git-sync";
|
||||
import type { GitDiffContentType, GitDiffStatus } from "metabase-types/api";
|
||||
|
||||
import { DiffRenderer } from "./components/DiffRenderer";
|
||||
import {
|
||||
CardRenderer,
|
||||
DefaultRenderer,
|
||||
TransformRenderer,
|
||||
} from "./components/EntityDiffRenderers";
|
||||
|
||||
interface ViewChangesModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
currentBranch: string;
|
||||
}
|
||||
|
||||
export const ViewChangesModal = ({
|
||||
opened,
|
||||
onClose,
|
||||
currentBranch,
|
||||
}: ViewChangesModalProps) => {
|
||||
const [selectedItem, setSelectedItem] = useState<number | null>(null);
|
||||
|
||||
const { data: branches = [] } = useListGitBranchesQuery();
|
||||
const currentBranchObj = branches.find((b) => b.name === currentBranch);
|
||||
|
||||
const { data: diffs = [], isLoading } = useGetGitDiffQuery(
|
||||
{ branchId: currentBranchObj?.id || 0 },
|
||||
{ skip: !opened || !currentBranchObj },
|
||||
);
|
||||
|
||||
const selectedDiff = diffs.find((d) => d.id === selectedItem) || diffs[0];
|
||||
|
||||
const getStatusText = (status: GitDiffStatus) => {
|
||||
switch (status) {
|
||||
case "added":
|
||||
return "A";
|
||||
case "modified":
|
||||
return "M";
|
||||
case "deleted":
|
||||
return "D";
|
||||
default:
|
||||
return "?";
|
||||
}
|
||||
};
|
||||
|
||||
const getContentTypeLabel = (contentType: GitDiffContentType) => {
|
||||
switch (contentType) {
|
||||
case "card":
|
||||
return t`Question`;
|
||||
case "transform":
|
||||
return t`Transform`;
|
||||
|
||||
default:
|
||||
return contentType;
|
||||
}
|
||||
};
|
||||
|
||||
const getEntityRenderer = (contentType: GitDiffContentType) => {
|
||||
switch (contentType) {
|
||||
case "card":
|
||||
return CardRenderer;
|
||||
case "transform":
|
||||
return TransformRenderer;
|
||||
default:
|
||||
return DefaultRenderer;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t`Changes in ${currentBranch}`}
|
||||
size="calc(100vw - 3rem)"
|
||||
styles={{
|
||||
content: {
|
||||
height: "calc(100vh - 3rem)",
|
||||
maxHeight: "calc(100vh - 3rem)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
padding: 0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Center h="100%">
|
||||
<Stack align="center">
|
||||
<Loader size="lg" />
|
||||
<Text c="dimmed">{t`Loading changes...`}</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : diffs.length === 0 ? (
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="xs">
|
||||
<Icon name="git_compare" size={40} color="text-light" />
|
||||
<Text fw={600} c="text-medium">
|
||||
{t`No changes found`}
|
||||
</Text>
|
||||
<Text size="sm" c="text-medium" ta="center" maw="25rem">
|
||||
{t`This branch has no changes compared to the main branch`}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
<Flex h="100%" style={{ overflow: "hidden" }}>
|
||||
<Box
|
||||
w={300}
|
||||
p="md"
|
||||
bg="var(--mb-color-bg-light)"
|
||||
style={{
|
||||
borderRight: "1px solid var(--mb-color-border)",
|
||||
}}
|
||||
>
|
||||
<Group mb="md" justify="space-between">
|
||||
<Text fw={600}>{t`Content changed`}</Text>
|
||||
<Text size="xs" c="text-medium">
|
||||
{diffs.length} {diffs.length === 1 ? "item" : "items"}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Stack gap={0}>
|
||||
{diffs.map((diff) => (
|
||||
<UnstyledButton
|
||||
key={diff.id}
|
||||
onClick={() => setSelectedItem(diff.id)}
|
||||
p="sm"
|
||||
bg={
|
||||
selectedItem === diff.id ||
|
||||
(!selectedItem && diff === selectedDiff)
|
||||
? "var(--mb-color-bg-medium)"
|
||||
: "transparent"
|
||||
}
|
||||
style={{
|
||||
borderRadius: 0,
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
"&:hover": {
|
||||
backgroundColor: "var(--mb-color-bg-medium)",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" justify="space-between" w="100%">
|
||||
<Box flex={1} miw={0}>
|
||||
<Text size="md" truncate>
|
||||
{diff.name}
|
||||
</Text>
|
||||
<Text size="xs" c="text-medium" truncate>
|
||||
{getContentTypeLabel(diff.content_type)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Badge
|
||||
size="xs"
|
||||
bg={
|
||||
diff.status === "added"
|
||||
? "success"
|
||||
: diff.status === "deleted"
|
||||
? "danger"
|
||||
: "brand"
|
||||
}
|
||||
c="white"
|
||||
>
|
||||
{getStatusText(diff.status)}
|
||||
</Badge>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box flex={1} style={{ overflow: "hidden" }}>
|
||||
{selectedDiff ? (
|
||||
<Stack h="100%" gap={0}>
|
||||
<Box
|
||||
p="md"
|
||||
bg="var(--mb-color-bg-light)"
|
||||
style={{
|
||||
borderBottom: "1px solid var(--mb-color-border)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Group gap="xs">
|
||||
<Text fw={600} size="sm">
|
||||
{selectedDiff.name}
|
||||
</Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
bg={
|
||||
selectedDiff.status === "added"
|
||||
? "success"
|
||||
: selectedDiff.status === "deleted"
|
||||
? "danger"
|
||||
: "brand"
|
||||
}
|
||||
c="white"
|
||||
>
|
||||
{selectedDiff.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="text-medium">
|
||||
{getContentTypeLabel(selectedDiff.content_type)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<ScrollArea flex={1}>
|
||||
<DiffRenderer
|
||||
diff={selectedDiff}
|
||||
renderContent={(entity) => {
|
||||
const Renderer = getEntityRenderer(
|
||||
selectedDiff.content_type,
|
||||
);
|
||||
return <Renderer entity={entity} />;
|
||||
}}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
) : (
|
||||
<Center h="100%">
|
||||
<Text c="dimmed">{t`Select an item to view changes`}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
</Flex>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { Box, Center, Flex, Stack, Text } from "metabase/ui";
|
||||
import type { GitDiff } from "metabase-types/api";
|
||||
|
||||
interface DiffRendererProps {
|
||||
diff: GitDiff;
|
||||
renderContent: (entity: any, type: "original" | "current") => ReactNode;
|
||||
height?: string | number;
|
||||
}
|
||||
|
||||
export function DiffRenderer({
|
||||
diff,
|
||||
renderContent,
|
||||
height = "400px",
|
||||
}: DiffRendererProps) {
|
||||
if (diff.status === "added" && diff.current) {
|
||||
return (
|
||||
<Stack gap="md" p="md">
|
||||
{renderContent(diff.current, "current")}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (diff.status === "deleted" && diff.original) {
|
||||
return (
|
||||
<Stack gap="md" p="md">
|
||||
<Box opacity={0.7}>{renderContent(diff.original, "original")}</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (diff.status === "modified" && diff.original && diff.current) {
|
||||
return (
|
||||
<Box p="md" style={{ minHeight: height }}>
|
||||
<Flex gap="md" h={height}>
|
||||
<Box flex={1}>
|
||||
<Text
|
||||
fw={600}
|
||||
c="text-medium"
|
||||
size="sm"
|
||||
mb="md"
|
||||
>{t`Original`}</Text>
|
||||
{renderContent(diff.original, "original")}
|
||||
</Box>
|
||||
<Box flex={1}>
|
||||
<Text fw={600} c="brand" size="sm" mb="md">{t`Current`}</Text>
|
||||
{renderContent(diff.current, "current")}
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Center h={height}>
|
||||
<Text c="text-medium" size="sm">{t`No content available`}</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
import _ from "underscore";
|
||||
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { Notebook } from "metabase/querying/notebook/components/Notebook";
|
||||
import { loadMetadataForCard } from "metabase/questions/actions";
|
||||
import { getMetadata } from "metabase/selectors/metadata";
|
||||
import { getSetting } from "metabase/selectors/settings";
|
||||
import { Box, Center, Code, Loader, Text } from "metabase/ui";
|
||||
import Question from "metabase-lib/v1/Question";
|
||||
|
||||
import { GitDiffVisualization } from "./GitDiffVisualization";
|
||||
|
||||
export const CardRenderer = ({ entity }: { entity: any }) => (
|
||||
<Box
|
||||
bg="var(--mb-color-bg-white)"
|
||||
bd="1px solid var(--mb-color-border)"
|
||||
bdrs="8px"
|
||||
p="16px"
|
||||
h="400px"
|
||||
style={{ overflow: "auto" }}
|
||||
>
|
||||
<GitDiffVisualization card={entity} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
export const TransformRenderer = ({ entity }: { entity: any }) => {
|
||||
const dispatch = useDispatch();
|
||||
const metadata = useSelector(getMetadata);
|
||||
const reportTimezone = useSelector((state) =>
|
||||
getSetting(state, "report-timezone-long"),
|
||||
);
|
||||
const [modifiedQuestion, setModifiedQuestion] = useState<Question | null>(
|
||||
null,
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const transformCard = useMemo(() => {
|
||||
if (!entity?.source?.query) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name: entity.name || "Transform",
|
||||
dataset_query: entity.source.query,
|
||||
display: "table" as const,
|
||||
visualization_settings: {},
|
||||
};
|
||||
}, [entity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!transformCard) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const cardForMetadata = _.omit(transformCard, "id");
|
||||
dispatch(loadMetadataForCard(cardForMetadata)).then(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [transformCard, dispatch]);
|
||||
|
||||
// Create question after metadata is loaded
|
||||
useEffect(() => {
|
||||
if (!isLoading && transformCard && metadata) {
|
||||
try {
|
||||
const baseQuestion = new Question(transformCard, metadata);
|
||||
setModifiedQuestion(baseQuestion);
|
||||
} catch (error) {
|
||||
console.warn("Failed to create transform question:", error);
|
||||
}
|
||||
}
|
||||
}, [isLoading, transformCard, metadata]);
|
||||
|
||||
// No-op function for read-only display
|
||||
const handleUpdateQuestion = async (newQuestion: Question) => {
|
||||
setModifiedQuestion(newQuestion);
|
||||
};
|
||||
|
||||
const isNativeQuery = entity?.source?.query?.type === "native";
|
||||
|
||||
if (isNativeQuery) {
|
||||
const sql = entity?.source?.query?.native?.query;
|
||||
if (!sql) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Code
|
||||
block
|
||||
bg="var(--mb-color-bg-white)"
|
||||
bd="1px solid var(--mb-color-border)"
|
||||
bdrs="8px"
|
||||
p="16px"
|
||||
h="100%"
|
||||
style={{
|
||||
overflow: "auto",
|
||||
fontSize: "12px",
|
||||
lineHeight: "1.5",
|
||||
}}
|
||||
>
|
||||
{sql}
|
||||
</Code>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center
|
||||
bg="var(--mb-color-bg-white)"
|
||||
bd="1px solid var(--mb-color-border)"
|
||||
bdrs="8px"
|
||||
p="16px"
|
||||
h="100%"
|
||||
style={{ overflow: "auto" }}
|
||||
>
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (!modifiedQuestion) {
|
||||
return (
|
||||
<Center
|
||||
bg="var(--mb-color-bg-white)"
|
||||
bd="1px solid var(--mb-color-border)"
|
||||
bdrs="8px"
|
||||
p="16px"
|
||||
h="100%"
|
||||
style={{ overflow: "auto" }}
|
||||
>
|
||||
<Text c="text-medium" size="sm">
|
||||
{t`Unable to load transform query`}
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg="var(--mb-color-bg-white)"
|
||||
bd="1px solid var(--mb-color-border)"
|
||||
bdrs="8px"
|
||||
p="16px"
|
||||
h="100%"
|
||||
style={{ overflow: "auto" }}
|
||||
>
|
||||
<Notebook
|
||||
question={modifiedQuestion}
|
||||
isDirty={false}
|
||||
isRunnable={false}
|
||||
isResultDirty={false}
|
||||
reportTimezone={reportTimezone}
|
||||
hasVisualizeButton={false}
|
||||
updateQuestion={handleUpdateQuestion}
|
||||
readOnly={true}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const DefaultRenderer = ({ entity }: { entity: any }) => (
|
||||
<Box
|
||||
bg="var(--mb-color-bg-white)"
|
||||
bd="1px solid var(--mb-color-border)"
|
||||
bdrs="8px"
|
||||
p="16px"
|
||||
h="100%"
|
||||
style={{ overflow: "auto" }}
|
||||
>
|
||||
<Text size="sm" c="text-medium">
|
||||
{JSON.stringify(entity, null, 2)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
@@ -0,0 +1,68 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { Center, Loader, Text } from "metabase/ui";
|
||||
import Visualization from "metabase/visualizations/components/Visualization";
|
||||
|
||||
import {
|
||||
type GitDiffCard,
|
||||
useGitDiffCardData,
|
||||
} from "../hooks/useGitDiffCardData";
|
||||
|
||||
interface GitDiffVisualizationProps {
|
||||
card: GitDiffCard;
|
||||
height?: string | number;
|
||||
}
|
||||
|
||||
export function GitDiffVisualization({
|
||||
card,
|
||||
height = "400px",
|
||||
}: GitDiffVisualizationProps) {
|
||||
const { series, isLoading, error, metadata, hasData } =
|
||||
useGitDiffCardData(card);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center h={height}>
|
||||
<Loader size="md" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Center h={height}>
|
||||
<Text c="text-medium" size="sm">
|
||||
{t`Failed to load visualization`}
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
// If we have data, render the full visualization
|
||||
if (series && hasData) {
|
||||
return (
|
||||
<Visualization rawSeries={series} metadata={metadata} height={height} />
|
||||
);
|
||||
}
|
||||
|
||||
// Render a placeholder if no data is available
|
||||
const placeholderCard = {
|
||||
...card,
|
||||
display: card.display || "table",
|
||||
visualization_settings: card.visualization_settings || {},
|
||||
};
|
||||
|
||||
return (
|
||||
<Visualization
|
||||
rawSeries={[
|
||||
{
|
||||
card: placeholderCard,
|
||||
data: null,
|
||||
},
|
||||
]}
|
||||
metadata={metadata}
|
||||
isPlaceholder
|
||||
height={height}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { skipToken } from "metabase/api";
|
||||
import { useGetAdhocQueryQuery } from "metabase/api/dataset";
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { getMetadata } from "metabase/selectors/metadata";
|
||||
import type { RawSeries } from "metabase-types/api";
|
||||
|
||||
export interface GitDiffCard {
|
||||
id?: number;
|
||||
name?: string;
|
||||
display?: string;
|
||||
database_id?: number;
|
||||
dataset_query?: any;
|
||||
visualization_settings?: any;
|
||||
result_metadata?: any;
|
||||
}
|
||||
|
||||
export function useGitDiffCardData(gitCard: GitDiffCard | null | undefined) {
|
||||
const metadata = useSelector(getMetadata);
|
||||
|
||||
const queryParams = useMemo(() => {
|
||||
if (!gitCard?.dataset_query) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const database =
|
||||
gitCard.database_id || gitCard.dataset_query?.database || null;
|
||||
|
||||
if (!database) {
|
||||
console.warn("No database ID found for git diff card:", gitCard);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...gitCard.dataset_query,
|
||||
database,
|
||||
parameters: [],
|
||||
};
|
||||
}, [gitCard]);
|
||||
|
||||
const {
|
||||
data: dataset,
|
||||
isLoading,
|
||||
error,
|
||||
isFetching,
|
||||
} = useGetAdhocQueryQuery(queryParams || skipToken);
|
||||
|
||||
const series: RawSeries | null = useMemo(() => {
|
||||
if (!gitCard || !dataset?.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cardForVisualization = {
|
||||
id: gitCard.id,
|
||||
name: gitCard.name || "Untitled Question",
|
||||
display: gitCard.display || "table",
|
||||
visualization_settings: gitCard.visualization_settings || {},
|
||||
dataset_query: gitCard.dataset_query,
|
||||
database_id: gitCard.database_id,
|
||||
result_metadata:
|
||||
dataset.data?.results_metadata || gitCard.result_metadata,
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
card: cardForVisualization,
|
||||
started_at: dataset.started_at,
|
||||
data: dataset.data,
|
||||
},
|
||||
];
|
||||
}, [gitCard, dataset]);
|
||||
|
||||
return {
|
||||
series,
|
||||
isLoading: isLoading || isFetching,
|
||||
error,
|
||||
metadata,
|
||||
hasData: !!dataset?.data,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { PLUGIN_GIT_SYNC } from "metabase/plugins";
|
||||
|
||||
import { GitChangeList } from "./GitChangeList";
|
||||
import { GitSyncSettings } from "./GitSyncSettings";
|
||||
import { LibraryNav } from "./LibraryNav";
|
||||
import { SelectBranch } from "./SelectBranch";
|
||||
import { ViewChangesButton } from "./ViewChangesButton";
|
||||
|
||||
// eslint-disable-next-line
|
||||
if ("git-sync-enabled" > "FIXME add a real token feature") {
|
||||
PLUGIN_GIT_SYNC.GitSyncSettings = GitSyncSettings;
|
||||
PLUGIN_GIT_SYNC.LibraryNav = LibraryNav;
|
||||
PLUGIN_GIT_SYNC.GitChangeList = GitChangeList;
|
||||
PLUGIN_GIT_SYNC.SelectBranch = SelectBranch;
|
||||
PLUGIN_GIT_SYNC.ViewChangesButton = ViewChangesButton;
|
||||
}
|
||||
|
||||
export { SelectBranch, ViewChangesButton };
|
||||
@@ -15,6 +15,7 @@ import "./collections";
|
||||
import "./content_translation";
|
||||
import "./content_verification";
|
||||
import "./database_routing";
|
||||
import "./git_sync";
|
||||
import "./whitelabel";
|
||||
import "./embedding";
|
||||
import "./embedding-sdk";
|
||||
|
||||
51
frontend/src/metabase-types/api/git-sync.ts
Normal file
51
frontend/src/metabase-types/api/git-sync.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export interface GitCommit {
|
||||
sha: string;
|
||||
message: string;
|
||||
author: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export interface GitBranch {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
creator_id: number;
|
||||
parent_branch_id?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateBranchRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
parent_branch_id?: number;
|
||||
}
|
||||
|
||||
export interface SwitchBranchRequest {
|
||||
branchName: string;
|
||||
}
|
||||
|
||||
export interface GitSyncStatus {
|
||||
currentBranch: string;
|
||||
isDirty: boolean;
|
||||
ahead: number;
|
||||
behind: number;
|
||||
}
|
||||
|
||||
export type GitDiffStatus = "added" | "modified" | "deleted";
|
||||
|
||||
export type GitDiffContentType =
|
||||
| "card"
|
||||
| "dashboard"
|
||||
| "collection"
|
||||
| "transform";
|
||||
|
||||
export interface GitDiff {
|
||||
id: number;
|
||||
name: string;
|
||||
status: GitDiffStatus;
|
||||
content_type: GitDiffContentType;
|
||||
current?: any;
|
||||
original?: any;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export * from "./email";
|
||||
export * from "./entity-id";
|
||||
export * from "./field";
|
||||
export * from "./geojson";
|
||||
export * from "./git-sync";
|
||||
export * from "./group";
|
||||
export * from "./logger";
|
||||
export * from "./metabot";
|
||||
|
||||
@@ -559,6 +559,7 @@ export type UserSettings = {
|
||||
"expand-browse-in-nav"?: boolean;
|
||||
"expand-bookmarks-in-nav"?: boolean;
|
||||
"browse-filter-only-verified-models"?: boolean;
|
||||
"git-branch"?: string | null;
|
||||
"browse-filter-only-verified-metrics"?: boolean;
|
||||
"show-updated-permission-modal": boolean;
|
||||
"show-updated-permission-banner": boolean;
|
||||
@@ -619,9 +620,26 @@ export type DatabaseReplicationConnections = Record<
|
||||
{ connection_id: string }
|
||||
>;
|
||||
|
||||
export type SyncableEntity =
|
||||
| "transform"
|
||||
| "snippet"
|
||||
| "dataset"
|
||||
| "metric"
|
||||
| "segment"
|
||||
| "dashboard"
|
||||
| "question";
|
||||
|
||||
export interface EnterpriseSettings extends Settings {
|
||||
"application-colors"?: ColorSettings | null;
|
||||
"application-logo-url"?: string;
|
||||
"git-sync-token"?: string | null;
|
||||
"git-sync-url"?: string | null;
|
||||
"git-sync-default-branch"?: string | null;
|
||||
"git-sync-import-branch"?: string | null; // import branch
|
||||
"git-sync-export-branch"?: string | null;
|
||||
"git-sync-entities"?: Partial<Record<SyncableEntity, boolean>> | null;
|
||||
"git-sync-configured"?: boolean | null;
|
||||
"git-sync-read-only"?: boolean;
|
||||
"login-page-illustration"?: IllustrationSettingValue;
|
||||
"login-page-illustration-custom"?: string;
|
||||
"landing-page-illustration"?: IllustrationSettingValue;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { UpsellGem } from "metabase/admin/upsells/components/UpsellGem";
|
||||
import { useHasTokenFeature } from "metabase/common/hooks";
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { PLUGIN_GIT_SYNC } from "metabase/plugins";
|
||||
import { getLocation } from "metabase/selectors/routing";
|
||||
import { Divider, Flex } from "metabase/ui";
|
||||
|
||||
@@ -46,6 +47,7 @@ export function SettingsNav() {
|
||||
{hasSaml && <SettingsNavItem path="authentication/saml" label="SAML" />}
|
||||
{hasJwt && <SettingsNavItem path="authentication/jwt" label="JWT" />}
|
||||
</SettingsNavItem>
|
||||
<PLUGIN_GIT_SYNC.LibraryNav />
|
||||
<NavDivider />
|
||||
<SettingsNavItem path="email" label={t`Email`} icon="mail" />
|
||||
<SettingsNavItem
|
||||
|
||||
@@ -2,7 +2,11 @@ import { IndexRedirect, Route } from "react-router";
|
||||
|
||||
import { AdminSettingsLayout } from "metabase/common/components/AdminLayout/AdminSettingsLayout";
|
||||
import { NotFound } from "metabase/common/components/ErrorPages";
|
||||
import { PLUGIN_ADMIN_SETTINGS, PLUGIN_AUTH_PROVIDERS } from "metabase/plugins";
|
||||
import {
|
||||
PLUGIN_ADMIN_SETTINGS,
|
||||
PLUGIN_AUTH_PROVIDERS,
|
||||
PLUGIN_GIT_SYNC,
|
||||
} from "metabase/plugins";
|
||||
|
||||
import { GoogleAuthForm } from "./settings/auth/components/GoogleAuthForm";
|
||||
import {
|
||||
@@ -60,6 +64,18 @@ export const getSettingsRoutes = () => (
|
||||
path="authentication/jwt"
|
||||
component={() => <PLUGIN_AUTH_PROVIDERS.SettingsJWTForm />}
|
||||
/>
|
||||
<Route
|
||||
path="library"
|
||||
component={() => <PLUGIN_GIT_SYNC.GitSyncSettings />}
|
||||
/>
|
||||
<Route
|
||||
path="library/sync"
|
||||
component={() => <PLUGIN_GIT_SYNC.GitSyncSettings />}
|
||||
/>
|
||||
<Route
|
||||
path="library/changes"
|
||||
component={() => <PLUGIN_GIT_SYNC.GitChangeList />}
|
||||
/>
|
||||
<Route path="maps" component={MapsSettingsPage} />
|
||||
<Route path="localization" component={LocalizationSettingsPage} />
|
||||
<Route path="uploads" component={UploadSettingsPage} />
|
||||
|
||||
@@ -125,6 +125,7 @@ export default class ActionButton extends Component {
|
||||
failedClassName,
|
||||
forceActiveStyle,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
} = this.props;
|
||||
const { active, result } = this.state;
|
||||
@@ -146,6 +147,7 @@ export default class ActionButton extends Component {
|
||||
})
|
||||
}
|
||||
onClick={this.onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{active ? (
|
||||
useLoadingSpinner ? (
|
||||
|
||||
@@ -24,7 +24,8 @@ export const AppBanner = () => {
|
||||
const isAdmin = useSelector(getUserIsAdmin);
|
||||
const isHosted = useSelector(getIsHosted);
|
||||
const tokenStatus = useSetting("token-status");
|
||||
const readOnly = useSetting("read-only-mode");
|
||||
const migrateReadOnly = useSetting("read-only-mode");
|
||||
const gitReadOnly = useSetting("git-sync-read-only");
|
||||
const isDevMode = useSetting("development-mode?");
|
||||
|
||||
const { shouldShowLicenseTokenMissingBanner, dismissBanner } =
|
||||
@@ -44,7 +45,7 @@ export const AppBanner = () => {
|
||||
return isDevMode ? <DevModeBanner /> : null;
|
||||
}
|
||||
|
||||
if (readOnly) {
|
||||
if (migrateReadOnly || gitReadOnly) {
|
||||
return <ReadOnlyBanner />;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
background: var(--mb-color-bg-white);
|
||||
border: 1px solid var(--mb-color-border);
|
||||
border-radius: var(--mantine-radius-xl);
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
color: var(--mb-color-text-primary);
|
||||
flex: 0 0 auto;
|
||||
|
||||
@@ -88,13 +88,13 @@
|
||||
border: 1px solid var(--mb-color-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
max-width: var(--max-width);
|
||||
|
||||
background: var(--mb-color-bg-white);
|
||||
padding: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.listEntityIcon {
|
||||
color: var(--mb-color-text-light);
|
||||
|
||||
&:hover {
|
||||
color: var(--mb-color-brand);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@ import LogoIcon from "metabase/common/components/LogoIcon";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { useRegisterShortcut } from "metabase/palette/hooks/useRegisterShortcut";
|
||||
import { PLUGIN_GIT_SYNC } from "metabase/plugins";
|
||||
import { getIsPaidPlan } from "metabase/selectors/settings";
|
||||
import { getUserIsAdmin } from "metabase/selectors/user";
|
||||
import { Button, Icon } from "metabase/ui";
|
||||
import { Button, Flex, Group, Icon } from "metabase/ui";
|
||||
import type { AdminPath } from "metabase-types/store";
|
||||
|
||||
import StoreLink from "../StoreLink";
|
||||
@@ -42,6 +43,7 @@ export const AdminNavbar = ({
|
||||
const isPaidPlan = useSelector(getIsPaidPlan);
|
||||
const isAdmin = useSelector(getUserIsAdmin);
|
||||
const dispatch = useDispatch();
|
||||
const gitSyncConfigured = useSelector((_state) => true);
|
||||
|
||||
useRegisterShortcut(
|
||||
[
|
||||
@@ -91,11 +93,17 @@ export const AdminNavbar = ({
|
||||
))}
|
||||
</AdminNavbarItems>
|
||||
|
||||
{!isPaidPlan && isAdmin && <StoreLink />}
|
||||
<AdminExitLink
|
||||
to="/"
|
||||
data-testid="exit-admin"
|
||||
>{t`Exit admin`}</AdminExitLink>
|
||||
<Flex ml="auto" align="center" gap="md">
|
||||
<Group ml="auto" gap="sm" display="flex" align="center" wrap="nowrap">
|
||||
{gitSyncConfigured && <PLUGIN_GIT_SYNC.SelectBranch />}
|
||||
{gitSyncConfigured && <PLUGIN_GIT_SYNC.ViewChangesButton />}
|
||||
</Group>
|
||||
{!isPaidPlan && isAdmin && <StoreLink />}
|
||||
<AdminExitLink
|
||||
to="/"
|
||||
data-testid="exit-admin"
|
||||
>{t`Exit admin`}</AdminExitLink>
|
||||
</Flex>
|
||||
</MobileHide>
|
||||
</AdminNavbarRoot>
|
||||
);
|
||||
@@ -108,7 +116,6 @@ interface AdminMobileNavbarProps {
|
||||
|
||||
const MobileNavbar = ({ adminPaths, currentPath }: AdminMobileNavbarProps) => {
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
|
||||
const ref = useClickOutside(() => setMobileNavOpen(false));
|
||||
|
||||
return (
|
||||
|
||||
@@ -58,7 +58,6 @@ const AppBarLarge = ({
|
||||
onLogout,
|
||||
}: AppBarLargeProps): JSX.Element => {
|
||||
const isNavBarVisible = isNavBarOpen && isNavBarEnabled;
|
||||
|
||||
return (
|
||||
<AppBarRoot
|
||||
hasSidebarOpen={
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { Banner } from "metabase/common/components/Banner";
|
||||
import { useSetting } from "metabase/common/hooks";
|
||||
import { Text } from "metabase/ui";
|
||||
|
||||
export const ReadOnlyBanner = () => {
|
||||
const gitSyncConfigured = useSetting("git-sync-read-only");
|
||||
|
||||
if (gitSyncConfigured) {
|
||||
return (
|
||||
<Banner
|
||||
bg="brand"
|
||||
c="white"
|
||||
body={
|
||||
<Text fw="bold" c="white">
|
||||
{/* eslint-disable-next-line no-literal-metabase-strings -- correct usage */}
|
||||
{t`Content in this Metabase instance is managed by serious people and cannot be changed here.`}
|
||||
</Text>
|
||||
}
|
||||
icon="info_filled"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Banner
|
||||
bg="warning"
|
||||
body={
|
||||
<Text fw="bold" color="text-dark">
|
||||
<Text fw="bold" c="text-dark">
|
||||
{/* eslint-disable-next-line no-literal-metabase-strings -- correct usage */}
|
||||
{t`Metabase is under maintenance and is operating in read-only mode. It should only take up to 30 minutes.`}
|
||||
</Text>
|
||||
|
||||
@@ -152,7 +152,6 @@ export function MainNavbarView({
|
||||
{t`Home`}
|
||||
</PaddedSidebarLink>
|
||||
</SidebarSection>
|
||||
|
||||
{shouldDisplayGettingStarted && (
|
||||
<SidebarSection>
|
||||
<ErrorBoundary>
|
||||
@@ -177,7 +176,6 @@ export function MainNavbarView({
|
||||
</ErrorBoundary>
|
||||
</SidebarSection>
|
||||
)}
|
||||
|
||||
{bookmarks.length > 0 && (
|
||||
<SidebarSection>
|
||||
<ErrorBoundary>
|
||||
@@ -192,7 +190,6 @@ export function MainNavbarView({
|
||||
</ErrorBoundary>
|
||||
</SidebarSection>
|
||||
)}
|
||||
|
||||
<SidebarSection>
|
||||
<ErrorBoundary>
|
||||
<CollectionSectionHeading
|
||||
@@ -217,7 +214,6 @@ export function MainNavbarView({
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
</SidebarSection>
|
||||
|
||||
<SidebarSection>
|
||||
<ErrorBoundary>
|
||||
<BrowseNavSection
|
||||
@@ -228,7 +224,6 @@ export function MainNavbarView({
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</SidebarSection>
|
||||
|
||||
{trashCollection && (
|
||||
<TrashSidebarSection>
|
||||
<ErrorBoundary>
|
||||
|
||||
@@ -849,3 +849,11 @@ export const PLUGIN_TRANSFORMS: TransformsPlugin = {
|
||||
getAdminPaths: () => [],
|
||||
getAdminRoutes: () => null,
|
||||
};
|
||||
|
||||
export const PLUGIN_GIT_SYNC = {
|
||||
LibraryNav: PluginPlaceholder,
|
||||
GitSyncSettings: NotFoundPlaceholder,
|
||||
GitChangeList: NotFoundPlaceholder,
|
||||
SelectBranch: PluginPlaceholder as ComponentType<{ disabled?: boolean }>,
|
||||
ViewChangesButton: PluginPlaceholder as ComponentType,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="4" x2="4" y1="2" y2="10" stroke-width="1.5"/>
|
||||
<circle cx="12" cy="4" r="2" stroke-width="1.5"/>
|
||||
<circle cx="4" cy="12" r="2" stroke-width="1.5"/>
|
||||
<path d="M12 6a6 6 0 0 1-6 6" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 316 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="5" cy="6" r="3"/>
|
||||
<path d="M12 6h5a2 2 0 0 1 2 2v7"/>
|
||||
<path d="m15 9-3-3 3-3"/>
|
||||
<circle cx="19" cy="18" r="3"/>
|
||||
<path d="M12 18H7a2 2 0 0 1-2-2V9"/>
|
||||
<path d="m9 15 3 3-3 3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 387 B |
@@ -178,6 +178,10 @@ import gear_settings_filled_component from "./gear_settings_filled.svg?component
|
||||
import gear_settings_filled_source from "./gear_settings_filled.svg?source";
|
||||
import gem_component from "./gem.svg?component";
|
||||
import gem_source from "./gem.svg?source";
|
||||
import git_branch_component from "./git_branch.svg?component";
|
||||
import git_branch_source from "./git_branch.svg?source";
|
||||
import git_compare_component from "./git_compare.svg?component";
|
||||
import git_compare_source from "./git_compare.svg?source";
|
||||
import globe_component from "./globe.svg?component";
|
||||
import globe_source from "./globe.svg?source";
|
||||
import google_component from "./google.svg?component";
|
||||
@@ -817,6 +821,14 @@ export const Icons: Record<IconName, { component: React.VFC; source: string }> =
|
||||
component: gem_component,
|
||||
source: gem_source,
|
||||
},
|
||||
git_branch: {
|
||||
component: git_branch_component,
|
||||
source: git_branch_source,
|
||||
},
|
||||
git_compare: {
|
||||
component: git_compare_component,
|
||||
source: git_compare_source,
|
||||
},
|
||||
globe: {
|
||||
component: globe_component,
|
||||
source: globe_source,
|
||||
@@ -1502,6 +1514,8 @@ export type IconName =
|
||||
| "gear"
|
||||
| "gear_settings_filled"
|
||||
| "gem"
|
||||
| "git_branch"
|
||||
| "git_compare"
|
||||
| "globe"
|
||||
| "grabber"
|
||||
| "grid"
|
||||
|
||||
@@ -1083,6 +1083,233 @@ databaseChangeLog:
|
||||
- customChange:
|
||||
class: "metabase.app_db.custom_migrations.MigrateClickHouseDetailsToMultiDB"
|
||||
|
||||
- changeSet:
|
||||
id: v57.2025-08-25T10:00:00
|
||||
author: edpaget
|
||||
comment: Create branch table
|
||||
changes:
|
||||
- createTable:
|
||||
tableName: branch
|
||||
remarks: Store branches for branched modeling
|
||||
columns:
|
||||
- column:
|
||||
name: id
|
||||
type: bigint
|
||||
autoIncrement: true
|
||||
constraints:
|
||||
primaryKey: true
|
||||
nullable: false
|
||||
- column:
|
||||
name: name
|
||||
type: varchar(254)
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: slug
|
||||
type: varchar(254)
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: created_at
|
||||
type: ${timestamp_type}
|
||||
defaultValueComputed: current_timestamp
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: updated_at
|
||||
type: ${timestamp_type}
|
||||
defaultValueComputed: current_timestamp
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: creator_id
|
||||
type: int
|
||||
constraints:
|
||||
nullable: true
|
||||
- column:
|
||||
name: parent_branch_id
|
||||
type: bigint
|
||||
constraints:
|
||||
nullable: true
|
||||
- column:
|
||||
name: is_main
|
||||
type: ${boolean.type}
|
||||
defaultValueBoolean: false
|
||||
constraints:
|
||||
nullable: true
|
||||
|
||||
- changeSet:
|
||||
id: v57.2025-08-25T10:00:10
|
||||
author: edpaget
|
||||
comment: Add foreign key constraint for branch.creator_id
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: branch
|
||||
baseColumnNames: creator_id
|
||||
referencedTableName: core_user
|
||||
referencedColumnNames: id
|
||||
constraintName: fk_branch_creator_id
|
||||
onDelete: SET NULL
|
||||
|
||||
- changeSet:
|
||||
id: v57.2025-08-25T10:00:20
|
||||
author: edpaget
|
||||
comment: Add foreign key constraint for branch.parent_branch_id
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: branch
|
||||
baseColumnNames: parent_branch_id
|
||||
referencedTableName: branch
|
||||
referencedColumnNames: id
|
||||
constraintName: fk_branch_parent_branch_id
|
||||
onDelete: SET NULL
|
||||
|
||||
- changeSet:
|
||||
id: v57.2025-08-25T10:00:30
|
||||
author: edpaget
|
||||
comment: Add index on branch.creator_id
|
||||
changes:
|
||||
- createIndex:
|
||||
tableName: branch
|
||||
indexName: idx_branch_creator_id
|
||||
columns:
|
||||
- column:
|
||||
name: creator_id
|
||||
|
||||
- changeSet:
|
||||
id: v57.2025-08-25T10:00:40
|
||||
author: edpaget
|
||||
comment: Add index on branch.parent_branch_id
|
||||
changes:
|
||||
- createIndex:
|
||||
tableName: branch
|
||||
indexName: idx_branch_parent_branch_id
|
||||
columns:
|
||||
- column:
|
||||
name: parent_branch_id
|
||||
|
||||
- changeSet:
|
||||
id: v57.2025-08-25T10:01:00
|
||||
author: edpaget
|
||||
comment: Create branch_model_mapping table
|
||||
changes:
|
||||
- createTable:
|
||||
tableName: branch_model_mapping
|
||||
remarks: Maps original models to branched models
|
||||
columns:
|
||||
- column:
|
||||
name: id
|
||||
type: bigint
|
||||
autoIncrement: true
|
||||
constraints:
|
||||
primaryKey: true
|
||||
nullable: false
|
||||
- column:
|
||||
name: original_id
|
||||
type: int
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: branched_model_id
|
||||
type: int
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: branch_id
|
||||
type: bigint
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: model_type
|
||||
type: varchar(50)
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: file_path
|
||||
type: ${text.type}
|
||||
constraints:
|
||||
nullable: true
|
||||
- column:
|
||||
name: is_dirty
|
||||
type: ${boolean.type}
|
||||
defaultValueBoolean: false
|
||||
constraints:
|
||||
nullable: true
|
||||
- column:
|
||||
name: last_synced_at
|
||||
type: ${timestamp_type}
|
||||
constraints:
|
||||
nullable: true
|
||||
- column:
|
||||
name: created_at
|
||||
type: ${timestamp_type}
|
||||
defaultValueComputed: current_timestamp
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: updated_at
|
||||
type: ${timestamp_type}
|
||||
defaultValueComputed: current_timestamp
|
||||
constraints:
|
||||
nullable: false
|
||||
|
||||
- changeSet:
|
||||
id: v57.2025-08-25T10:01:10
|
||||
author: edpaget
|
||||
comment: Add foreign key constraint for branch_model_mapping.branch_id
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: branch_model_mapping
|
||||
baseColumnNames: branch_id
|
||||
referencedTableName: branch
|
||||
referencedColumnNames: id
|
||||
constraintName: fk_branch_model_mapping_branch_id
|
||||
onDelete: CASCADE
|
||||
|
||||
- changeSet:
|
||||
id: v57.2025-08-25T10:01:20
|
||||
author: edpaget
|
||||
comment: Add unique index on branch_model_mapping
|
||||
changes:
|
||||
- addUniqueConstraint:
|
||||
tableName: branch_model_mapping
|
||||
columnNames: original_id, model_type, branch_id
|
||||
constraintName: idx_branch_model_mapping_unique
|
||||
|
||||
- changeSet:
|
||||
id: v57.2025-08-25T10:01:30
|
||||
author: edpaget
|
||||
comment: Add index on branch_model_mapping for branch_id and model_type
|
||||
changes:
|
||||
- createIndex:
|
||||
tableName: branch_model_mapping
|
||||
indexName: idx_branch_model_mapping_branch_model
|
||||
columns:
|
||||
- column:
|
||||
name: branch_id
|
||||
- column:
|
||||
name: model_type
|
||||
|
||||
- changeSet:
|
||||
id: v57.2025-08-25T10:01:40
|
||||
author: edpaget
|
||||
comment: Add index on branch_model_mapping.branched_model_id
|
||||
changes:
|
||||
- createIndex:
|
||||
tableName: branch_model_mapping
|
||||
indexName: idx_branch_model_mapping_branched_model
|
||||
columns:
|
||||
- column:
|
||||
name: branched_model_id
|
||||
|
||||
- changeSet:
|
||||
id: v57.2025-08-29T04:40:42
|
||||
author: nvoxland
|
||||
comment: Drop unique constraint for entity id that can now be duplicated
|
||||
changes:
|
||||
- customChange:
|
||||
class: "metabase.app_db.custom_migrations.DropUniqueConstraint" ## TODO: This should be rewritten to take tablename as an arg
|
||||
|
||||
# >>>>>>>>>> DO NOT ADD NEW MIGRATIONS BELOW THIS LINE! ADD THEM ABOVE <<<<<<<<<<
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
@@ -188,6 +188,31 @@
|
||||
:where [:or [:like :object (h2x/literal "/data/db/%")]
|
||||
[:like :object (h2x/literal "/query/db/%")]]}))
|
||||
|
||||
(define-migration DropUniqueConstraint
|
||||
(case (mdb.connection/db-type)
|
||||
:h2
|
||||
(let [constraint-name (-> (t2/query {:select [:CONSTRAINT_NAME]
|
||||
:from [:INFORMATION_SCHEMA.CONSTRAINT_COLUMN_USAGE]
|
||||
:where [:and
|
||||
[:= :TABLE_NAME "TRANSFORM"]
|
||||
[:= :COLUMN_NAME "ENTITY_ID"]]})
|
||||
first
|
||||
:constraint_name)]
|
||||
(t2/query [(str "ALTER TABLE TRANSFORM DROP CONSTRAINT " constraint-name)]))
|
||||
:postgres
|
||||
(let [constraint-name (-> (t2/query {:select [:constraint_name]
|
||||
:from [:information_schema.constraint_column_usage]
|
||||
:where [:and
|
||||
[:= :table_name "transform"]
|
||||
[:= :column_name "entity_id"]]})
|
||||
first
|
||||
:constraint_name)]
|
||||
(t2/query [(str "ALTER TABLE TRANSFORM DROP CONSTRAINT " constraint-name)]))
|
||||
|
||||
:else
|
||||
(throw (ex-info "Unsupported appdb type" {})))
|
||||
(log/info "Dropped unique constraint on transform.entity_id"))
|
||||
|
||||
;;; +----------------------------------------------------------------------------------------------------------------+
|
||||
;;; | Quartz Scheduler Helpers |
|
||||
;;; +----------------------------------------------------------------------------------------------------------------+
|
||||
|
||||
44
src/metabase/branching/core.clj
Normal file
44
src/metabase/branching/core.clj
Normal file
@@ -0,0 +1,44 @@
|
||||
(ns metabase.branching.core
|
||||
"Middleware that handles setting the current branch."
|
||||
(:require
|
||||
[metabase.premium-features.core :refer [defenterprise]]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(def ^:dynamic *current-branch*
|
||||
"The current 'branch' for a request in Metabase. Some models support forking changes
|
||||
into related groups of changes -- called a 'branch' -- that transparently replace the
|
||||
original model when the branch is activated."
|
||||
(delay nil))
|
||||
|
||||
(def ^:dynamic *enable-branch-hook*
|
||||
"Controls whether the toucan pipeline hook is enabled, this exists so we can disable it
|
||||
while running code like custom migrations that we do not want it enabled for."
|
||||
true)
|
||||
|
||||
(defn do-with-current-branch-var
|
||||
"Impl for with-current-branch-var"
|
||||
[branch-name thunk]
|
||||
(binding [*current-branch* (if branch-name (delay (t2/select-one :model/Branch :name branch-name)) (delay nil))]
|
||||
(thunk)))
|
||||
|
||||
(defmacro with-current-branch-var
|
||||
[branch & body]
|
||||
`(do-with-current-branch-var ~branch (^:once fn* [] ~@body)))
|
||||
|
||||
(defenterprise get-current-branch
|
||||
"Extract the current branch from the X-Metabase-Branch header used to extract
|
||||
branched models from the common pk depending on the branch set.
|
||||
|
||||
In OSS this does not set the branch."
|
||||
metabase-enterprise.branching.core
|
||||
[_request]
|
||||
nil)
|
||||
|
||||
(defn handle-current-branch
|
||||
"Ring middleware to set the current branch using the `get-current-branch` function."
|
||||
[handler]
|
||||
(fn [request respond raise]
|
||||
(if-let [current-branch (get-current-branch request)]
|
||||
(with-current-branch-var current-branch
|
||||
(handler (assoc request :current-branch current-branch) respond raise))
|
||||
(handler request respond raise))))
|
||||
@@ -974,7 +974,7 @@
|
||||
;; :total_count
|
||||
:limit (if (zero? limit) 1 limit)
|
||||
:offset offset))
|
||||
rows (mdb/query limit-query)
|
||||
rows (t2/query limit-query)
|
||||
res {:total (->> rows first :total_count)
|
||||
:data (if (= limit 0)
|
||||
[]
|
||||
|
||||
@@ -125,6 +125,7 @@
|
||||
(doto :model/Collection
|
||||
(derive :metabase/model)
|
||||
(derive :hook/entity-id)
|
||||
(derive :hook/git-sync-protected)
|
||||
(derive ::mi/read-policy.full-perms-for-perms-set)
|
||||
(derive ::mi/write-policy.full-perms-for-perms-set))
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
[metabase.analytics.core :as analytics]
|
||||
[metabase.api-routes.core :as api-routes]
|
||||
[metabase.app-db.core :as mdb]
|
||||
[metabase.branching.core :as branching]
|
||||
[metabase.classloader.core :as classloader]
|
||||
[metabase.cloud-migration.core :as cloud-migration]
|
||||
[metabase.config.core :as config]
|
||||
@@ -135,42 +136,43 @@
|
||||
;; we have to skip creating sample content if we're running tests, because it causes some tests to timeout
|
||||
;; and the test suite can take 2x longer. this is really unfortunate because it could lead to some false
|
||||
;; negatives, but for now there's not much we can do
|
||||
(mdb/setup-db! :create-sample-content? (not config/is-test?))
|
||||
;; Disable read-only mode if its on during startup.
|
||||
;; This can happen if a cloud migration process dies during h2 dump.
|
||||
(when (cloud-migration/read-only-mode)
|
||||
(cloud-migration/read-only-mode! false))
|
||||
(init-status/set-progress! 0.4)
|
||||
;; Set up Prometheus
|
||||
(log/info "Setting up prometheus metrics")
|
||||
(analytics/setup!)
|
||||
(init-status/set-progress! 0.5)
|
||||
(premium-features/airgap-check-user-count)
|
||||
(init-status/set-progress! 0.55)
|
||||
(task/init-scheduler!)
|
||||
(analytics/add-listeners-to-scheduler!)
|
||||
;; run a very quick check to see if we are doing a first time installation
|
||||
;; the test we are using is if there is at least 1 User in the database
|
||||
(let [new-install? (not (setup/has-user-setup))]
|
||||
;; initialize Metabase from an `config.yml` file if present (Enterprise Edition™ only)
|
||||
(config-from-file/init-from-file-if-code-available!)
|
||||
(init-status/set-progress! 0.6)
|
||||
(when new-install?
|
||||
(log/info "Looks like this is a new installation ... preparing setup wizard")
|
||||
;; create setup token
|
||||
(create-setup-token-and-log-setup-url!)
|
||||
;; publish install event
|
||||
(events/publish-event! :event/install {}))
|
||||
(init-status/set-progress! 0.7)
|
||||
;; deal with our sample database as needed
|
||||
(when (config/load-sample-content?)
|
||||
(if new-install?
|
||||
;; add the sample database DB for fresh installs
|
||||
(sample-data/extract-and-sync-sample-database!)
|
||||
;; otherwise update if appropriate
|
||||
(sample-data/update-sample-database-if-needed!)))
|
||||
(init-status/set-progress! 0.8))
|
||||
(ensure-audit-db-installed!)
|
||||
(binding [branching/*enable-branch-hook* false]
|
||||
(mdb/setup-db! :create-sample-content? (not config/is-test?))
|
||||
;; Disable read-only mode if its on during startup.
|
||||
;; This can happen if a cloud migration process dies during h2 dump.
|
||||
(when (cloud-migration/read-only-mode)
|
||||
(cloud-migration/read-only-mode! false))
|
||||
(init-status/set-progress! 0.4)
|
||||
;; Set up Prometheus
|
||||
(log/info "Setting up prometheus metrics")
|
||||
(analytics/setup!)
|
||||
(init-status/set-progress! 0.5)
|
||||
(premium-features/airgap-check-user-count)
|
||||
(init-status/set-progress! 0.55)
|
||||
(task/init-scheduler!)
|
||||
(analytics/add-listeners-to-scheduler!)
|
||||
;; run a very quick check to see if we are doing a first time installation
|
||||
;; the test we are using is if there is at least 1 User in the database
|
||||
(let [new-install? (not (setup/has-user-setup))]
|
||||
;; initialize Metabase from an `config.yml` file if present (Enterprise Edition™ only)
|
||||
(config-from-file/init-from-file-if-code-available!)
|
||||
(init-status/set-progress! 0.6)
|
||||
(when new-install?
|
||||
(log/info "Looks like this is a new installation ... preparing setup wizard")
|
||||
;; create setup token
|
||||
(create-setup-token-and-log-setup-url!)
|
||||
;; publish install event
|
||||
(events/publish-event! :event/install {}))
|
||||
(init-status/set-progress! 0.7)
|
||||
;; deal with our sample database as needed
|
||||
(when (config/load-sample-content?)
|
||||
(if new-install?
|
||||
;; add the sample database DB for fresh installs
|
||||
(sample-data/extract-and-sync-sample-database!)
|
||||
;; otherwise update if appropriate
|
||||
(sample-data/update-sample-database-if-needed!)))
|
||||
(init-status/set-progress! 0.8))
|
||||
(ensure-audit-db-installed!))
|
||||
(notification/seed-notification!)
|
||||
|
||||
(init-status/set-progress! 0.9)
|
||||
|
||||
17
src/metabase/core/git_source_of_truth.clj
Normal file
17
src/metabase/core/git_source_of_truth.clj
Normal file
@@ -0,0 +1,17 @@
|
||||
(ns metabase.core.git-source-of-truth
|
||||
(:require
|
||||
[metabase.classloader.core :as classloader]
|
||||
[metabase.util.log :as log]))
|
||||
|
||||
(defn initialize-from-git-if-available!
|
||||
"Shim for running the git source of truth code, used by [[metabase.core.core]]. The git source of truth code only ships in
|
||||
the Enterprise Edition™ JAR, so this checks whether the namespace exists, and if it does,
|
||||
invokes [[metabase-enterprise.git-source-of-truth.core/initialize-from-git!]]; otherwise, this no-ops."
|
||||
[]
|
||||
(when (try
|
||||
(classloader/require 'metabase-enterprise.git-source-of-truth.core)
|
||||
:ok
|
||||
(catch Throwable _
|
||||
(log/debug "metabase-enterprise.git-source-of-truth.core not available; cannot initialize from git.")
|
||||
nil))
|
||||
((resolve 'metabase-enterprise.git-source-of-truth.core/initialize-from-git!))))
|
||||
@@ -40,7 +40,8 @@
|
||||
(derive :metabase/model)
|
||||
(derive :perms/use-parent-collection-perms)
|
||||
(derive :hook/timestamped?)
|
||||
(derive :hook/entity-id))
|
||||
(derive :hook/entity-id)
|
||||
(derive :hook/git-sync-protected))
|
||||
|
||||
(defmethod mi/can-write? :model/Dashboard
|
||||
([instance]
|
||||
|
||||
@@ -31,7 +31,9 @@
|
||||
[toucan2.core :as t2]
|
||||
[toucan2.model :as t2.model]
|
||||
[toucan2.protocols :as t2.protocols]
|
||||
[toucan2.tools.after :as t2.after]
|
||||
[toucan2.tools.before-insert :as t2.before-insert]
|
||||
[toucan2.tools.before-update :as t2.before-update]
|
||||
[toucan2.tools.hydrate :as t2.hydrate]
|
||||
[toucan2.tools.identity-query :as t2.identity-query]
|
||||
[toucan2.util :as t2.u])
|
||||
@@ -58,6 +60,10 @@
|
||||
on a future deserialization."
|
||||
false)
|
||||
|
||||
(def ^:dynamic *syncing-source-of-truth-entities*
|
||||
"This is dynamically bound to the set of entities that we are syncing when syncing the source of truth."
|
||||
#{})
|
||||
|
||||
(def ^{:arglists '([x & _args])} dispatch-on-model
|
||||
"Helper dispatch function for multimethods. Dispatches on the first arg, using [[models.dispatch/model]]."
|
||||
;; make sure model namespace gets loaded e.g. `:model/Database` should load `metabase.model.database` if needed.
|
||||
@@ -579,9 +585,34 @@
|
||||
(-> instance
|
||||
add-entity-id))
|
||||
|
||||
(defn- prevent-sync-protected-writes
|
||||
"Prevents writes to entities that are synced to source of truth, unless explicitly allowed."
|
||||
[instance]
|
||||
(when (and (try ((requiring-resolve 'metabase-enterprise.git-source-of-truth.settings/git-sync-read-only))
|
||||
(catch Exception _ false))
|
||||
(not *deserializing?*))
|
||||
(throw (ex-info "Cannot modify entities synced to source of truth"
|
||||
{:entity-id (:entity_id instance)
|
||||
:model (model instance)})))
|
||||
instance)
|
||||
|
||||
(t2/define-before-insert :hook/git-sync-protected
|
||||
[instance]
|
||||
(prevent-sync-protected-writes instance))
|
||||
|
||||
(t2/define-before-update :hook/git-sync-protected
|
||||
[instance]
|
||||
(prevent-sync-protected-writes instance))
|
||||
|
||||
(methodical/prefer-method! #'t2.before-insert/before-insert :hook/timestamped? :hook/entity-id)
|
||||
(methodical/prefer-method! #'t2.before-insert/before-insert :hook/updated-at-timestamped? :hook/entity-id)
|
||||
(methodical/prefer-method! #'t2.before-insert/before-insert :hook/created-at-timestamped? :hook/entity-id)
|
||||
(methodical/prefer-method! #'t2.before-insert/before-insert :hook/entity-id :hook/git-sync-protected)
|
||||
(methodical/prefer-method! #'t2.before-insert/before-insert :hook/timestamped? :hook/git-sync-protected)
|
||||
(methodical/prefer-method! #'t2.before-update/before-update :hook/entity-id :hook/git-sync-protected)
|
||||
(methodical/prefer-method! #'t2.before-update/before-update :hook/timestamped? :hook/git-sync-protected)
|
||||
(methodical/prefer-method! #'t2.after/each-row-fn [:toucan.query-type/insert.* :hook/branchable]
|
||||
[:toucan.query-type/insert.* :hook/search-index])
|
||||
|
||||
;; --- helper fns
|
||||
(defn changes-with-pk
|
||||
@@ -648,6 +679,14 @@
|
||||
{:arglists '([instance] [model pk])}
|
||||
dispatch-on-model)
|
||||
|
||||
(defmethod can-write? :hook/git-sync-protected
|
||||
([instance]
|
||||
(try (false? ((requiring-resolve 'metabase-enterprise.git-source-of-truth.settings/git-sync-read-only)))
|
||||
(catch Exception _ true)))
|
||||
([_model _pk]
|
||||
(try (false? ((requiring-resolve 'metabase-enterprise.git-source-of-truth.settings/git-sync-read-only)))
|
||||
(catch Exception _ true))))
|
||||
|
||||
#_{:clj-kondo/ignore [:unused-private-var]}
|
||||
(define-simple-hydration-method ^:private hydrate-can-write
|
||||
:can_write
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
:model/ApplicationPermissionsRevision metabase.permissions.models.application-permissions-revision
|
||||
:model/AuditLog metabase.audit-app.models.audit-log
|
||||
:model/BookmarkOrdering metabase.bookmarks.models.bookmark
|
||||
:model/Branch metabase-enterprise.branching.models.branch
|
||||
:model/BranchModelMapping metabase-enterprise.branching.models.branch-model-mapping
|
||||
:model/CacheConfig metabase.cache.models.cache-config
|
||||
:model/Card metabase.queries.models.card
|
||||
:model/CardBookmark metabase.bookmarks.models.bookmark
|
||||
|
||||
@@ -115,8 +115,10 @@
|
||||
(derive :metabase/model)
|
||||
;; You can read/write a Card if you can read/write its parent Collection
|
||||
(derive :perms/use-parent-collection-perms)
|
||||
;(derive :hook/branchable)
|
||||
(derive :hook/timestamped?)
|
||||
(derive :hook/entity-id))
|
||||
(derive :hook/entity-id)
|
||||
(derive :hook/git-sync-protected))
|
||||
|
||||
(defmethod mi/can-write? :model/Card
|
||||
([instance]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
(:require
|
||||
[metabase.analytics.core :as analytics]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.branching.core :as branching]
|
||||
[metabase.config.core :as config]
|
||||
[metabase.server.middleware.auth :as mw.auth]
|
||||
[metabase.server.middleware.browser-cookie :as mw.browser-cookie]
|
||||
@@ -66,6 +67,7 @@
|
||||
#'mw.security/add-security-headers ; Add HTTP headers to API responses to prevent them from being cached
|
||||
#'mw.json/wrap-json-body ; extracts json POST/PUT body and makes it available on request
|
||||
#'mw.offset-paging/handle-paging ; binds per-request parameters to handle paging
|
||||
#'branching/handle-current-branch ; binds per-request branch parameters to handle getting branchable models
|
||||
#'mw.json/wrap-streamed-json-response ; middleware to automatically serialize suitable objects as JSON in responses
|
||||
#'wrap-keyword-params ; converts string keys in :params to keyword keys
|
||||
#'wrap-params ; parses GET and POST params as :query-params/:form-params and both as :params
|
||||
|
||||
@@ -124,3 +124,12 @@
|
||||
:type :keyword
|
||||
:default :all
|
||||
:audit :raw-value)
|
||||
|
||||
(defsetting git-branch
|
||||
(deferred-tru "The git branch of the Metabase instance.")
|
||||
:encryption :no
|
||||
:user-local :only
|
||||
:export? false
|
||||
:visibility :authenticated
|
||||
:type :string
|
||||
:default nil)
|
||||
|
||||
@@ -121,6 +121,13 @@
|
||||
:model/Action
|
||||
(fn [_] {:creator_id (rasta-id)})
|
||||
|
||||
:model/Branch
|
||||
(fn [_] (default-timestamped
|
||||
(let [name (u.random/random-name)]
|
||||
{:name name
|
||||
:slug (u/slugify name)
|
||||
:creator_id (rasta-id)})))
|
||||
|
||||
:model/Channel
|
||||
(fn [_] (default-timestamped
|
||||
{:name (u.random/random-name)
|
||||
|
||||
Reference in New Issue
Block a user