Compare commits

...

176 Commits

Author SHA1 Message Date
Ryan Laurie
5124d54f56 mark date filter picker tests flaky (#54866) 2025-03-07 17:21:01 -07:00
Bryan Maass
2731328399 switch off flaky test setup 2025-03-07 12:08:06 -07:00
Ryan Laurie
8f3fb59b04 update loki icon reference image 2025-03-07 10:44:13 -07:00
Bryan Maass
adacc94594 Revert "remove unused schema"
This schema is actually used! 🎉

This reverts commit f5dbd78051.
2025-03-06 15:35:16 -07:00
Bryan Maass
f5dbd78051 remove unused schema 2025-03-06 15:00:22 -07:00
Bryan Maass
d134a5d7fd add logging if any part of the batch-delete fails 2025-03-06 14:55:12 -07:00
Bryan Maass
59c1ef35e5 review responses 2025-03-06 14:25:20 -07:00
Filipe Silva
32f9a75a54 Merge branch 'master' into gsheets_integration 2025-03-06 14:42:55 +00:00
Bryan Maass
9bb90460d7 Merge remote-tracking branch 'origin/master' into gsheets_integration 2025-03-05 10:04:24 -07:00
Bryan Maass
7c38c4036b allow resolution of snake_cased setting names 2025-03-05 10:01:46 -07:00
Bryan Maass
fbc845fd60 comment 2025-03-04 15:03:21 -07:00
Bryan Maass
74b9005c8d Refines prometheus events
They are now:

- :metabase-gsheets/connection-creation-began
- :metabase-gsheets/connection-creation-ok
- :metabase-gsheets/connection-creation-error {:reason "status_error"}
- :metabase-gsheets/connection-creation-error {:reason "timeout"}
- :metabase-gsheets/connection-deleted
2025-03-04 14:50:08 -07:00
Bryan Maass
443f6d8e74 Merge branch 'gsheets_integration' of github.com:metabase/metabase into gsheets_integration 2025-03-04 14:05:48 -07:00
Bryan Maass
5cf32ea819 Merge remote-tracking branch 'origin/master' into gsheets_integration 2025-03-04 14:05:01 -07:00
Bryan Maass
38b93facb0 adds prometheus metrics to gsheets api
- :metabase-gsheets/sync-creation-begin
- :metabase-gsheets/sync-creation-ok
- :metabase-gsheets/sync-creation-error
  - failure-reason is one of:
  - "timeout" - gsheets.api/sync-complete? didn't return true after 10 minutes
  - "status_error" - HM said the sync connection had status == error
- :metabase-gsheets/connection-deleted
2025-03-04 10:28:20 -07:00
Ryan Laurie
f95d6acf49 remove resolved comment 2025-03-04 09:20:46 -07:00
bryan
9061a460a4 Merge branch 'master' into gsheets_integration 2025-03-03 13:58:19 -07:00
Ryan Laurie
9a04bf493f fix strings 2025-03-03 07:59:54 -07:00
Filipe Silva
2beba8f368 build: push ee-extra to dev repo, not ee
Cloud wants ee-extra, not just ee
2025-03-03 14:17:44 +00:00
Bryan Maass
01adde965d respond to review comments
+ fix timeout 50000 -> 5000
+ make internal regex function private
2025-02-28 15:07:15 -07:00
Bryan Maass
62c467d3a9 Merge remote-tracking branch 'origin/master' into gsheets_integration 2025-02-28 12:23:42 -07:00
Ryan Laurie
9f631c5af1 fix fe token mock 2025-02-27 09:07:43 -07:00
Ryan Laurie
9d20764356 fix e2e tests 2025-02-26 16:50:21 -07:00
Bryan Maass
6c8df23f1d better docstrings 2025-02-26 15:02:50 -07:00
Bryan Maass
ac7788a57e better docstring 2025-02-26 14:59:38 -07:00
Bryan Maass
cab579d47c add a test 2025-02-26 11:55:16 -07:00
Bryan Maass
4a34f0d66e update some tests 2025-02-26 11:54:00 -07:00
Bryan Maass
b14a85f11c fix schema name 2025-02-26 11:30:35 -07:00
Bryan Maass
5533fc24d1 add simple schema snowplow event schema 2025-02-26 10:55:25 -07:00
bryan
0a48c2c8e9 Merge branch 'master' into gsheets_integration 2025-02-26 10:42:24 -07:00
Bryan Maass
835a20a3f5 update module linter config 2025-02-26 10:40:27 -07:00
Ryan Laurie
b680129886 update frontend tests 2025-02-25 12:02:37 -07:00
Ryan Laurie
a8a1a33e9f remove test code 2025-02-25 09:53:00 -07:00
Ryan Laurie
a1495c1630 hard code error message 2025-02-25 09:46:19 -07:00
Bryan Maass
45cf7d3689 ::snowplow/simple_event -> :snowplow/simple_event 2025-02-17 10:11:13 -07:00
Bryan Maass
22f69ce466 sort ns 2025-02-14 16:45:09 -07:00
Bryan Maass
e6e873719f setup api-key setting before reading it 2025-02-14 16:36:23 -07:00
Bryan Maass
aeaeb2d783 cljfmt 2025-02-14 16:07:25 -07:00
bryan
224e62590a Merge branch 'master' into gsheets_integration 2025-02-14 15:52:05 -07:00
Bryan Maass
705a058248 fix typo 2025-02-14 15:50:49 -07:00
Bryan Maass
4e164d7631 module linter 2025-02-14 15:46:20 -07:00
Bryan Maass
b2bbca61d0 fix the tests 2025-02-14 15:34:37 -07:00
Bryan Maass
9224fa69e1 kondo 2025-02-14 15:18:33 -07:00
Bryan Maass
da52a4e20e add harbormaster module 2025-02-14 14:50:56 -07:00
Bryan Maass
bb03090c71 add docstring 2025-02-14 14:49:47 -07:00
Bryan Maass
3611c61018 fix modules 2025-02-14 14:49:21 -07:00
Bryan Maass
765ee8f2a3 Merge branch 'gsheets_integration' of github.com:metabase/metabase into gsheets_integration 2025-02-14 14:46:29 -07:00
bryan
89e9367091 Merge branch 'master' into gsheets_integration 2025-02-14 14:35:05 -07:00
Bryan Maass
a1866264e2 remove comment 2025-02-14 14:09:59 -07:00
Bryan Maass
892f685aa3 Merge remote-tracking branch 'origin/master' into gsheets_integration 2025-02-14 13:24:35 -07:00
Bryan Maass
5160f35563 gdrive-conn is now unused 2025-02-14 11:20:07 -07:00
Bryan Maass
3375d66624 remove temp gsheets
- token status checks happen once per 12h
- remove hm/conn and mb/sync-info from :get /folder response
2025-02-14 11:17:59 -07:00
Bryan Maass
0e8be7ddf5 modularize gsheets 2025-02-14 11:15:12 -07:00
Bryan Maass
6a3d8fb918 Merge branch 'gsheets_integration' of github.com:metabase/metabase into gsheets_integration 2025-02-14 11:01:48 -07:00
Bryan Maass
397462437e improve crufty/name? api 2025-02-14 10:59:23 -07:00
Ryan Laurie
581333f12d status click target update 2025-02-14 10:35:49 -07:00
Ryan Laurie
637a4e8343 update error copy 2025-02-14 10:35:49 -07:00
Ryan Laurie
09f5e8ab75 update modal copy 2025-02-14 10:35:49 -07:00
Bryan Maass
f997161dff kondo 2025-02-14 10:11:16 -07:00
Bryan Maass
bf08654036 respond to Noah's review comments 2025-02-13 11:06:15 -07:00
bryan
9b5d33f529 Update enterprise/backend/src/metabase_enterprise/gsheets.clj
Co-authored-by: Noah Moss <32746338+noahmoss@users.noreply.github.com>
2025-02-13 10:51:50 -07:00
bryan
0ae503bd3d Update enterprise/backend/src/metabase_enterprise/advanced_config/file/databases.clj
Co-authored-by: Noah Moss <32746338+noahmoss@users.noreply.github.com>
2025-02-13 10:41:32 -07:00
bryan
7da201019a Update enterprise/backend/src/metabase_enterprise/advanced_config/file/databases.clj
Co-authored-by: Noah Moss <32746338+noahmoss@users.noreply.github.com>
2025-02-13 10:41:03 -07:00
bryan
4d3421514f Update src/metabase/sync/sync_metadata/crufty.clj
Co-authored-by: Noah Moss <32746338+noahmoss@users.noreply.github.com>
2025-02-13 10:40:09 -07:00
Bryan Maass
612adfc137 Merge branch 'gsheets_integration' of github.com:metabase/metabase into gsheets_integration 2025-02-12 14:34:23 -07:00
Bryan Maass
7c6201bb55 solve initial-sync-status-table-only-test failure 2025-02-12 14:32:28 -07:00
bryan
a3fcb4f3ce Merge branch 'master' into gsheets_integration 2025-02-12 10:46:35 -07:00
Bryan Maass
baf779a43b Merge branch 'gsheets_integration' of github.com:metabase/metabase into gsheets_integration 2025-02-10 14:44:27 -07:00
Bryan Maass
b56cb91e80 bust cache on gsheets setting 2025-02-10 14:43:00 -07:00
Ryan Laurie
a8184c5473 save dbId and actually show it 2025-02-10 14:18:34 -07:00
Bryan Maass
efe424c839 Merge branch 'gsheets_integration' of github.com:metabase/metabase into gsheets_integration 2025-02-07 10:52:45 -07:00
Bryan Maass
b5a2535120 reset gsheets status on every error response 2025-02-07 10:52:19 -07:00
Ryan Laurie
ca894691e8 test code for bruyan 2025-02-07 10:25:24 -07:00
Bryan Maass
a8c8eab61f remove def + actually run the test 2025-02-07 10:22:53 -07:00
Bryan Maass
0ec9b93db6 add cruft columns config.yml setup tests 2025-02-07 10:21:39 -07:00
Bryan Maass
d495049090 add get folder timeout test, update dox,
- remove regex folder check
2025-02-07 10:09:14 -07:00
Bryan Maass
3491bc5b5e kondo + add empty cruft tables test 2025-02-07 09:45:12 -07:00
Ryan Laurie
06001a5d41 handle going back to loading state 2025-02-06 16:25:12 -07:00
Bryan Maass
6ebe43ba9d bump gsheets sync timeout to 10 seconds 2025-02-06 10:52:18 -07:00
Bryan Maass
5d7c6df470 add auto cruft fields test 2025-02-06 10:43:31 -07:00
Bryan Maass
5075a2c41e Merge remote-tracking branch 'origin/master' into gsheets_integration 2025-02-06 09:55:34 -07:00
Bryan Maass
475c9131a7 Merge branch 'gsheets_integration' of github.com:metabase/metabase into gsheets_integration 2025-02-06 09:37:32 -07:00
Bryan Maass
418caa9a97 more idiomatic crufty db-local settings 2025-02-06 09:22:44 -07:00
Chris Truter
8fb9411f96 Fix search index update job restart issue (#53229) 2025-02-06 16:11:19 +02:00
Bryan Maass
98bcfd9d14 Merge branch 'gsheets_integration' of github.com:metabase/metabase into gsheets_integration 2025-02-05 14:32:41 -07:00
Bryan Maass
bd70bf9a71 cruft settings are db-local 2025-02-05 14:03:27 -07:00
Ryan Laurie
afd62d8d82 prettier 2025-02-04 17:13:56 -07:00
Ryan Laurie
e4f67eeec4 remove test code 2025-02-04 17:12:52 -07:00
Bryan Maass
6d0d1a436b rename macro double -> run-twice 2025-02-04 16:43:09 -07:00
Bryan Maass
bbedae0c04 fixing tests 2025-02-04 16:15:57 -07:00
Bryan Maass
b5d5aaa707 fix update-field-metadata-if-needed! signature 2025-02-04 16:03:13 -07:00
Bryan Maass
7e0724b019 cljfmt + kebab case stragglers 2025-02-04 15:19:03 -07:00
Bryan Maass
463feaa297 use kebab-case for settings but accept snake_cased 2025-02-04 15:11:27 -07:00
Bryan Maass
18d62ac5d0 migrate to underscores for auto_cruft_*
- add tests for field + fix schema for table syncs
2025-02-04 13:23:51 -07:00
Bryan Maass
574bb511fe kondo + cljfmt 2025-02-04 11:35:25 -07:00
Bryan Maass
b39963ab61 add harbormaster client test 2025-02-04 11:12:24 -07:00
Bryan Maass
929861482c fix gsheets tests 2025-02-04 09:59:07 -07:00
Bryan Maass
122415b359 fix a few tests 2025-02-03 16:55:43 -07:00
Bryan Maass
d97cff1f14 indent 2025-02-03 16:50:57 -07:00
Bryan Maass
e62b3ba407 auto cruft columns
- pull the regex comparrison code into crufty ns
  - reuse it on 2
- add defsetting so having the setting on the database doesn't complain
- pass database down into field syncing + grab vis types
2025-02-03 16:46:46 -07:00
Bryan Maass
f0ea6501ed fix update-table-metadata
- (no longer update nil -> nil)
2025-02-03 14:57:12 -07:00
Bryan Maass
9e2a9f77fa remove some extra comments + use hm-delete-conn 2025-02-03 14:56:46 -07:00
Bryan Maass
091bdaabc1 fail to load invalid + present cruft patterns 2025-02-03 14:46:03 -07:00
Bryan Maass
753d8c95fe cleanup hm.client
- indentation

fix [[make-request]] args
2025-02-03 14:21:10 -07:00
Bryan Maass
a98ec213dd recover from suddenly-missing gdrive conn @ id 2025-02-03 14:19:03 -07:00
Bryan Maass
294fd9b754 simplify api use via gdrive conn id
- move ->config into hm.client ns
2025-02-03 14:14:33 -07:00
Bryan Maass
3e7304b461 add docstring to dynamic timeout var 2025-02-03 11:05:21 -07:00
Ryan Laurie
e3f2ea665f update regex 2025-02-03 09:21:59 -07:00
Ryan Laurie
1dd29634c4 loosen up gdrive link validation 2025-02-03 07:39:19 -07:00
Bryan Maass
568583eec5 update gsheets routes to use the new ns-handler 2025-01-31 09:44:05 -07:00
Bryan Maass
213c0f0eb9 Merge remote-tracking branch 'origin/master' into gsheets_integration 2025-01-31 09:29:52 -07:00
Bryan Maass
3decf08c56 Delete connections on reset
- Change order of timeout checks in :get /folder handler
- add auto-cruft-columns setting
2025-01-30 16:43:51 -07:00
Ryan Laurie
d40f62f5cd remove fake error state 2025-01-30 16:41:47 -07:00
Ryan Laurie
ffd2c1b99f refresh setting on error 2025-01-30 16:08:59 -07:00
Ryan Laurie
a2a99c3969 better FE sync state and form validation 2025-01-30 15:33:14 -07:00
Bryan Maass
cbfeef8525 Add 10 minute timeout for polling connection
- return errors as json-encoded :error in the response body
- minor cleanup how we auto cruft tables (no change to behavior)
- add script for running the flow locally: from the repl
2025-01-30 14:48:58 -07:00
Ryan Laurie
9fec86d330 don't show reconnect copy from DB page 2025-01-29 11:56:24 -07:00
Ryan Laurie
ca2ca78389 add frontend analytics events 2025-01-29 11:33:17 -07:00
Ryan Laurie
57b77eede5 update sync unit tests 2025-01-29 11:22:15 -07:00
Ryan Laurie
af886e1562 update sync unit tests 2025-01-29 11:20:13 -07:00
Ryan Laurie
df0fcea0d4 fix complete sync state and add error state 2025-01-29 11:14:36 -07:00
Bryan Maass
844c15b288 indentation 2025-01-28 16:21:45 -07:00
Bryan Maass
3368978419 if no gdrive conns are found, reset state
- add gdrive sorting tests
2025-01-28 16:20:51 -07:00
Bryan Maass
bb1fa36bb2 joda time Instant/ofEpochSecond 0 -> t/instant 0 2025-01-28 10:31:50 -07:00
Bryan Maass
64dac7cbae Merge remote-tracking branch 'origin/master' into gsheets_integration 2025-01-28 10:19:32 -07:00
Bryan Maass
a1d5c78400 cleanup 2025-01-28 10:07:38 -07:00
Bryan Maass
9ecdac937a when multiple gdrive conns, sort by status, then created-at time 2025-01-27 16:44:54 -07:00
Bryan Maass
7470bbf938 Merge branch 'gsheets_integration' of github.com:metabase/metabase into gsheets_integration 2025-01-27 16:15:08 -07:00
Bryan Maass
7af05870d1 documentation 2025-01-27 16:14:48 -07:00
bryan
eee7a7d0a3 Merge branch 'master' into gsheets_integration 2025-01-27 16:13:39 -07:00
Bryan Maass
2e2cee42ec add commment + mock hm responses 2025-01-27 15:20:47 -07:00
Bryan Maass
3f5fd0dba5 mocked api tests 2025-01-27 15:11:49 -07:00
Bryan Maass
295c4c88cd add bunch of tests for auto-cruft 2025-01-24 16:59:35 -07:00
Bryan Maass
6dc9336b2a add auto-cruft-tables to metabase_database.setting
- Here's the contract:

There should be an array of strings located at: db > settings >
"auto-cruft-tables". Each string will get fed into re-pattern. The table
names get lowercased before getting matched on. That list of regexes is
combined with our built in list of crufty regex patterns. We use re-find
to match them against the lower-cased table names. If there's a match we
will mark the table crufty (this is called a "Hidden Table" in the UI).
This way, if someone names their sheet `I'mUnique____dlt_loads` or
whatever they can at least fish it out of the hidden section.

Note: It will get set back to being crufty (aka what the ui calls
hidden) on the next sync... but this is configurable so we can fine-tune
the regexes to match the tables we want to hide more closely.

Examples:

If you put ["a"] in the config.yml: any table with A or a in it will be
marked hidden. So be sure to use anchors for the suffix or what-not. For
our thing, I think we want something like:
2025-01-24 16:05:15 -07:00
Bryan Maass
de8f546a4c handle multiple gdrive connections
- on delete, delete them all
- when looking at syncing, look at the most recent one
2025-01-24 14:49:09 -07:00
Bryan Maass
2d3487eff7 fix config.edn (howd it break tho?) 2025-01-24 14:27:32 -07:00
Bryan Maass
1e9c9e7d1e only show _active_ dwh tables 2025-01-23 14:10:56 -07:00
Bryan Maass
6ea150fc4b FIXME FOR TESTING - set token-status-cache-ttl to 1 second (vs 12 hrs) 2025-01-23 13:24:30 -07:00
Ryan Laurie
8a7bd5045d add database page entrypoint 2025-01-23 12:00:17 -07:00
Bryan Maass
8fd3ff1896 fail on sync error + partially allow deletes 2025-01-23 11:39:53 -07:00
Ryan Laurie
fa34fad2b9 FIXME FOR TESTING - disable feature checks 2025-01-23 11:20:40 -07:00
Bryan Maass
0c9a96a9f7 add debug info 2025-01-23 09:28:15 -07:00
Bryan Maass
2e951b3aee reset gsheet status when there is no gdrive conn 2025-01-23 09:00:21 -07:00
Ryan Laurie
b7d64355a3 make form submit on enter 2025-01-22 17:18:52 -07:00
Bryan Maass
cd4cc4fe7f + tests and error status codes 2025-01-22 16:28:29 -07:00
Bryan Maass
1bd92f6f73 differentiate failure modes 2025-01-22 13:47:44 -07:00
Bryan Maass
09dfde1127 add and use simple events 2025-01-22 13:45:31 -07:00
Bryan Maass
e3b53fe8de model/Table has a db_id column not database_id 2025-01-22 11:36:05 -07:00
Bryan Maass
0bc0e0aa16 gate things behind the right features
- only show gsheets button
- http requests to /api/ee/gsheets/*
2025-01-22 10:02:31 -07:00
Filipe Silva
aac3e98efc Merge branch 'master' into gsheets_integration 2025-01-22 10:55:32 +00:00
Ryan Laurie
22cd58ecae Gsheets frontend (#51946)
* non-oauth UI

* update api and add disconnect modal

* update illustration and add error displays

* add gsheets sync status

* better copy

* remove test code'

* fix bad api error

* updates to sync status

* Update sidebar entrypoint
2025-01-21 16:22:33 -07:00
Bryan Maass
3f5a479b0e fix :delete /folder + schema on :get /folder 2025-01-21 11:06:55 -07:00
bryan
777ebb8e83 gsheets: update polling logic + better errors (#52162)
* update polling logic + better errors

* codify protocol for polling folder done-ness

* align on FE polling logic + the real sync complete

* [WIP] testing happy path

* status should be "active"

- remove a bunch of extra debugging

* remove outdated tests + cljfmt

* check admin + use the connection's id w/ delete

- cleanup
2025-01-17 16:26:24 -07:00
bryan
89c735addc Gsheets table management (#52145)
* include dwh ∈ GET /api/ee/upload-management/tables

* cljfmt

* simplify preliminary gsheet definition

* archive cards is a query param
2025-01-14 16:20:47 -07:00
bryan
248984121a Merge branch 'master' into gsheets_integration 2025-01-14 14:22:25 -07:00
Bryan Maass
3788b58668 [kondo] defendpoint -> defendpoint2 2025-01-14 14:16:05 -07:00
Bryan Maass
a0e8862bc4 Merge remote-tracking branch 'origin' into gsheets_integration 2025-01-14 11:39:19 -07:00
bryan
0586b45957 Gsheets service account be flow (#51997)
* update setup flow to use service-account

* align GET api/ee/gsheets/service-account response

* align the HM service account endpoint

* fix migration yaml

* update test for service accounts

* indentation + migration quibble

* improve error messages + kondo housekeeping

* remove unreachable if-let branch + fix serdes test

* add is_gsheet to mock and test data

* add gsheets setting to serialization test

* fix fe-type-check

* typechecker
2025-01-13 16:58:50 -07:00
Bryan Maass
0bfe20aa04 add migration for is_gsheet to metabase_table 2025-01-09 15:00:35 -07:00
Bryan Maass
ded3142afe require the right namespace 2025-01-09 14:36:48 -07:00
Bryan Maass
3d451f696e gsheets and hm http client => enterprise
+ move tests too
2024-12-20 15:57:08 -07:00
Bryan Maass
b8c2455294 save folder_owner to gsheets 2024-12-19 19:43:29 -07:00
Bryan Maass
b668285350 rely on more hm stubs 2024-12-19 17:10:53 -07:00
Bryan Maass
f73ff5e2ba More cleaning up
- update docstring
- remove declares + use {set,get}-value-of-type{!,}
- remove nil state from gsheets setting
2024-12-19 17:02:27 -07:00
Bryan Maass
8ebace1d58 move settings out of auth middleware ns
- update kondo modules
- add metabase.api.auth ns
2024-12-19 15:31:41 -07:00
Filipe Silva
d6f77080d2 build: push ee-extra to dev repo, not ee
Cloud wants ee-extra, not just ee
2024-12-19 15:11:20 +00:00
Filipe Silva
c66bec5b46 fix: delay u.secret instantiation to prevent build race condition
See https://metaboat.slack.com/archives/CKZEMT1MJ/p1734605793293989
2024-12-19 14:09:49 +00:00
Filipe Silva
a4ffbd7bba fix: declare gsheet setting fn before using in getter 2024-12-19 11:02:21 +00:00
bryan
207650011d Merge branch 'master' into gsheets_integration 2024-12-18 21:35:31 -07:00
bryan
925b9941ee Gsheets integration: more api alignment (#51492)
* add frontend for gsheets sync

* update frontend to use changed API

* wip

* cleanup

---------

Co-authored-by: Ryan Laurie <iethree@gmail.com>
2024-12-18 20:06:27 -07:00
Ryan Laurie
6fac0bd29f update frontend to use changed API 2024-12-18 13:46:20 -07:00
Ryan Laurie
7fd163db17 add frontend for gsheets sync 2024-12-18 13:46:20 -07:00
bryan
e042d6abea Merge branch 'master' into gsheets_integration 2024-12-17 20:08:35 -07:00
Bryan Maass
4408624e7d Merge branch 'master' into gsheets_integration 2024-12-17 16:38:26 -07:00
bryan
b0b4cd38a3 Gsheets mb api (#51307)
* send flag to show "connect gsheets" button to FE

* clean up docstring + add etl-connections condition

* metabase api WIP

* align APIs

* include site-url in the HM temp-url api call

* cleanup

* update HM<->MB api to be working (minus temp-url)

* cleanup

* remove some notes

* indentation + linting

* add tests

* fix typo

* remove illustrative (when true ...)
2024-12-17 16:37:01 -07:00
Bryan Maass
9d470220ea add fall-through for ignored embedding requests 2024-12-17 09:14:39 -07:00
70 changed files with 2595 additions and 231 deletions

View File

@@ -464,6 +464,7 @@
metabase.sync.field-values sync.field-values
metabase.sync.interface i
metabase.sync.schedules sync.schedules
metabase.sync.sync-metadata.crufty crufty
metabase.sync.sync-metadata.fields.our-metadata fields.our-metadata
metabase.sync.util sync-util
metabase.sync.util-test sync.util-test

View File

@@ -60,7 +60,8 @@
:uses #{config driver legacy-mbql lib models sync util}}
api
{:api #{metabase.api.cache
{:api #{metabase.api.auth
metabase.api.cache
metabase.api.card
metabase.api.collection
metabase.api.common
@@ -603,7 +604,8 @@
enterprise/advanced-config
{:api #{metabase-enterprise.advanced-config.api.logs
metabase-enterprise.advanced-config.models.pulse-channel
metabase-enterprise.advanced-config.file}
metabase-enterprise.advanced-config.file
metabase-enterprise.advanced-config.crufty}
:uses #{api db driver models premium-features setup sync util}}
enterprise/advanced-permissions
@@ -665,6 +667,14 @@
{:api #{metabase-enterprise.enhancements.init}
:uses #{models premium-features sso util enterprise/sso}}
enterprise/gsheets
{:api #{metabase-enterprise.gsheets.api}
:uses #{api analytics enterprise/harbormaster models premium-features server util}}
enterprise/harbormaster
{:api #{metabase-enterprise.harbormaster.client}
:uses #{api cloud-migration models util}}
enterprise/impersonation
{:api #{metabase-enterprise.impersonation.core
metabase-enterprise.impersonation.api}
@@ -738,7 +748,7 @@
enterprise/upload-management
{:api #{metabase-enterprise.upload-management.api}
:uses #{api models upload util}}}
:uses #{api models premium-features upload util}}}
;; namespaces matching these patterns (with `re-find`) are excluded from the module linter. Since regex literals
;; aren't allowed in EDN just used the `(str <regex>)` version i.e. two slashes instead of one.

View File

@@ -208,7 +208,7 @@ jobs:
push: true
- name: Retag and push images if dev branch - should be cached at this point
if: ${{ !(startsWith(github.ref_name,'master') || startsWith(github.ref_name,'backport')) && matrix.edition == 'ee' }}
if: ${{ !(startsWith(github.ref_name,'master') || startsWith(github.ref_name,'backport')) && matrix.edition == 'ee-extra' }}
uses: docker/build-push-action@v3
with:
context: bin/docker/.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 161 KiB

View File

@@ -24,7 +24,10 @@ H.describeWithSnowplow(
testFile.fileName
} to "Our analytics" using DWH`, () => {
cy.visit("/");
cy.findByTestId("main-navbar-root").findByText("Upload CSV").click();
cy.findByTestId("main-navbar-root")
.findByText(/Add Data/)
.click();
H.popover().findByText("Upload CSV").click();
H.uploadFile("#dwh-upload-csv-input", "Our analytics", testFile);

View File

@@ -20,84 +20,92 @@ describe("issue 39487", () => {
cy.viewport(1280, 1000);
});
it("calendar has constant size when using single date picker filter (metabase#39487)", () => {
createTimeSeriesQuestionWithFilter([">", CREATED_AT_FIELD, "2015-01-01"]); // 5 day rows
it(
"calendar has constant size when using single date picker filter (metabase#39487)",
{ tags: "@flaky" },
() => {
createTimeSeriesQuestionWithFilter([">", CREATED_AT_FIELD, "2015-01-01"]); // 5 day rows
cy.log("timeseries filter button");
cy.findByTestId("timeseries-filter-button").click();
checkSingleDateFilter();
cy.log("timeseries filter button");
cy.findByTestId("timeseries-filter-button").click();
checkSingleDateFilter();
cy.log("filter pills");
cy.findByTestId("filters-visibility-control").click();
cy.findByTestId("filter-pill").click();
checkSingleDateFilter();
cy.log("filter pills");
cy.findByTestId("filters-visibility-control").click();
cy.findByTestId("filter-pill").click();
checkSingleDateFilter();
cy.log("filter modal");
cy.button(/Filter/).click();
H.modal().findByText("After Jan 1, 2015").click();
checkSingleDateFilter();
H.modal().button("Close").click();
cy.log("filter modal");
cy.button(/Filter/).click();
H.modal().findByText("After Jan 1, 2015").click();
checkSingleDateFilter();
H.modal().button("Close").click();
cy.log("filter drill");
cy.findByLabelText("Switch to data").click();
H.tableHeaderClick("Created At: Year");
H.popover().findByText("Filter by this column").click();
H.popover().findByText("Specific dates…").click();
H.popover().findByText("After").click();
H.popover().findByRole("textbox").clear().type("2015/01/01");
checkSingleDateFilter();
cy.log("filter drill");
cy.findByLabelText("Switch to data").click();
H.tableHeaderClick("Created At: Year");
H.popover().findByText("Filter by this column").click();
H.popover().findByText("Specific dates…").click();
H.popover().findByText("After").click();
H.popover().findByRole("textbox").clear().type("2015/01/01");
checkSingleDateFilter();
cy.log("notebook editor");
H.openNotebook();
H.getNotebookStep("filter")
.findAllByTestId("notebook-cell-item")
.first()
.click();
checkSingleDateFilter();
});
cy.log("notebook editor");
H.openNotebook();
H.getNotebookStep("filter")
.findAllByTestId("notebook-cell-item")
.first()
.click();
checkSingleDateFilter();
},
);
it("calendar has constant size when using date range picker filter (metabase#39487)", () => {
createTimeSeriesQuestionWithFilter([
"between",
CREATED_AT_FIELD,
"2024-05-01", // 5 day rows
"2024-06-01", // 6 day rows
]);
it(
"calendar has constant size when using date range picker filter (metabase#39487)",
{ tags: "@flaky" },
() => {
createTimeSeriesQuestionWithFilter([
"between",
CREATED_AT_FIELD,
"2024-05-01", // 5 day rows
"2024-06-01", // 6 day rows
]);
cy.log("timeseries filter button");
cy.findByTestId("timeseries-filter-button").click();
checkDateRangeFilter();
cy.log("timeseries filter button");
cy.findByTestId("timeseries-filter-button").click();
checkDateRangeFilter();
cy.log("filter pills");
cy.findByTestId("filters-visibility-control").click();
cy.findByTestId("filter-pill").click();
checkDateRangeFilter();
cy.log("filter pills");
cy.findByTestId("filters-visibility-control").click();
cy.findByTestId("filter-pill").click();
checkDateRangeFilter();
cy.log("filter modal");
cy.button(/Filter/).click();
H.modal().findByText("May 1 – Jun 1, 2024").click();
checkDateRangeFilter();
H.modal().button("Close").click();
cy.log("filter modal");
cy.button(/Filter/).click();
H.modal().findByText("May 1 – Jun 1, 2024").click();
checkDateRangeFilter();
H.modal().button("Close").click();
cy.log("filter drill");
cy.findByLabelText("Switch to data").click();
H.tableHeaderClick("Created At: Year");
H.popover().findByText("Filter by this column").click();
H.popover().findByText("Specific dates…").click();
H.popover().findAllByRole("textbox").first().clear().type("2024/05/01");
// eslint-disable-next-line no-unsafe-element-filtering
H.popover().findAllByRole("textbox").last().clear().type("2024/06/01");
previousButton().click();
checkDateRangeFilter();
cy.log("filter drill");
cy.findByLabelText("Switch to data").click();
H.tableHeaderClick("Created At: Year");
H.popover().findByText("Filter by this column").click();
H.popover().findByText("Specific dates…").click();
H.popover().findAllByRole("textbox").first().clear().type("2024/05/01");
// eslint-disable-next-line no-unsafe-element-filtering
H.popover().findAllByRole("textbox").last().clear().type("2024/06/01");
previousButton().click();
checkDateRangeFilter();
cy.log("notebook editor");
H.openNotebook();
H.getNotebookStep("filter")
.findAllByTestId("notebook-cell-item")
.first()
.click();
checkDateRangeFilter();
});
cy.log("notebook editor");
H.openNotebook();
H.getNotebookStep("filter")
.findAllByTestId("notebook-cell-item")
.first()
.click();
checkDateRangeFilter();
},
);
it("date picker is scrollable when overflows (metabase#39487)", () => {
cy.viewport(1280, 800);

View File

@@ -1,6 +1,8 @@
(ns metabase-enterprise.advanced-config.file.databases
(:require
[clojure.set :as set]
[clojure.spec.alpha :as s]
[medley.core :as m]
[metabase-enterprise.advanced-config.file.interface :as advanced-config.file.i]
[metabase.driver.util :as driver.u]
[metabase.models.setting :refer [defsetting]]
@@ -26,15 +28,42 @@
(s/def :metabase-enterprise.advanced-config.file.databases.config-file-spec/details
map?)
(defn- valid-regex-patterns? [patterns]
(every? (fn [pattern]
(try
(boolean (re-pattern pattern))
(catch Exception e (log/error e) false)))
patterns))
(s/def :metabase-enterprise.advanced-config.file.databases.config-file-spec/settings
(s/and
map?
(fn cruft-patterns-are-valid? [settings]
(->> [(:auto-cruft-tables settings
;; we access auto.cruft.* with _'s here. Because we may expect to see underscores from
;; the yaml file, this is validated first then normalized into kebab-case after
;; validation in [[normalize-settings]].
(:auto_cruft_tables settings))
(:auto-cruft-columns settings
(:auto_cruft_columns settings))]
(remove nil?)
(every? valid-regex-patterns?)))))
(s/def ::config-file-spec
(s/keys :req-un [:metabase-enterprise.advanced-config.file.databases.config-file-spec/engine
:metabase-enterprise.advanced-config.file.databases.config-file-spec/name
:metabase-enterprise.advanced-config.file.databases.config-file-spec/details]))
:metabase-enterprise.advanced-config.file.databases.config-file-spec/details]
:opt-un [:metabase-enterprise.advanced-config.file.databases.config-file-spec/settings]))
(defmethod advanced-config.file.i/section-spec :databases
[_section]
(s/spec (s/* ::config-file-spec)))
(defn- normalize-settings [db]
(m/update-existing db :settings set/rename-keys
{:auto_cruft_tables :auto-cruft-tables
:auto_cruft_columns :auto-cruft-columns}))
(defn- init-from-config-file!
[database]
(if (contains? database :delete)
@@ -55,10 +84,10 @@
(if-let [existing-database-id (t2/select-one-pk :model/Database :engine (:engine database), :name (:name database))]
(do
(log/info (u/format-color :blue "Updating Database %s %s" (:engine database) (pr-str (:name database))))
(t2/update! :model/Database existing-database-id database))
(t2/update! :model/Database existing-database-id (normalize-settings database)))
(do
(log/info (u/format-color :green "Creating new %s Database %s" (:engine database) (pr-str (:name database))))
(let [db (first (t2/insert-returning-instances! :model/Database database))]
(let [db (first (t2/insert-returning-instances! :model/Database (normalize-settings database)))]
(if (config-from-file-sync-databases)
(let [sync-database! (requiring-resolve 'metabase.sync.core/sync-database!)]
(quick-task/submit-task! (fn [] (sync-database! db))))

View File

@@ -12,6 +12,7 @@
[metabase-enterprise.billing.api.routes]
[metabase-enterprise.content-verification.api.routes]
[metabase-enterprise.data-editing.api]
[metabase-enterprise.gsheets.api :as gsheets.api]
[metabase-enterprise.llm.api]
[metabase-enterprise.query-reference-validation.api]
[metabase-enterprise.sandbox.api.routes]
@@ -28,8 +29,10 @@
(def ^:private required-feature->message
{:advanced-permissions (deferred-tru "Advanced Permissions")
:attached-dwh (deferred-tru "Attached DWH")
:audit-app (deferred-tru "Audit app")
:collection-cleanup (deferred-tru "Collection Cleanup")
:etl-connections (deferred-tru "ETL Connections")
:table-data-editing (deferred-tru "Editing Table Data")
:llm-autodescription (deferred-tru "LLM Auto-description")
:query-reference-validation (deferred-tru "Query Reference Validation")
@@ -61,6 +64,9 @@
"/autodescribe" (premium-handler 'metabase-enterprise.llm.api :llm-autodescription)
"/billing" metabase-enterprise.billing.api.routes/routes
"/data-editing" (premium-handler metabase-enterprise.data-editing.api/routes :table-data-editing)
"/gsheets" (-> gsheets.api/routes ;; gsheets requires both features.
(premium-handler :attached-dwh)
(premium-handler :etl-connections))
"/logs" (premium-handler 'metabase-enterprise.advanced-config.api.logs :audit-app)
"/query-reference-validation" (premium-handler metabase-enterprise.query-reference-validation.api/routes :query-reference-validation)
"/scim" (premium-handler metabase-enterprise.scim.routes/routes :scim)

View File

@@ -0,0 +1,337 @@
(ns metabase-enterprise.gsheets.api
(:require
[java-time.api :as t]
[medley.core :as m]
[metabase-enterprise.gsheets.constants :as gsheets.constants]
[metabase-enterprise.gsheets.settings
:as gsheets.settings
:refer [gsheets gsheets!]]
[metabase-enterprise.harbormaster.client :as hm.client]
[metabase.analytics.core :as analytics]
[metabase.analytics.snowplow :as snowplow]
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.util :as u]
[metabase.util.date-2 :as u.date]
[metabase.util.i18n :refer [tru]]
[metabase.util.json :as json]
[metabase.util.log :as log]
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr]
[metabase.util.malli.schema :as ms]
[toucan2.core :as t2]))
;; # Google Sheets Integration
;;
;; ## Overview
;; Google Sheets Integration allows users to connect their Google Drive folders to Metabase. We sync any Google Sheets
;; inside of those folders, and make them available to the user in their attached data warehouse. This integration is
;; implemented using Harbormaster, which is a service that manages instances of Metabase and also gives us a connection
;; to Google Sheets data. When a user connects a Google Drive folder to Metabase, Harbormaster creates a connection to
;; the folder and starts syncing the data to Metabase. This data is then available to the user in Metabase as tables.
;;
;; ## Steps to connect a new google drive folder
;;
;; - An admin clicks Add Data > Connect Google Sheets
;; - FE sends :get "api/ee/gsheets/service-account" to get service-account email
;; - If the FE cannot find the service-account email, it does not show the "connect to google sheets" (ctgs) button.
;; - user clicks ctgs button -> FE shows instructions for how to connect a folder:
;; - user shares their Google Drive folder w/ service-account email
;; - user copies their Google Drive share link into MB form and hits submit
;; - FE sends :post "api/ee/gsheets/folder" w/ folder url string as `request.body.url`
;; - BE forwards request to :post "/api/v2/mb/connections" w/ body:
;; `{:type "gdrive" :secret {:resources ["the-url"]}}`
;; - on unexceptional status:
;; - BE sets `gsheets.status` to `"loading"`
;; - BE returns the gsheets shape: `{:status "loading" :folder_url "the-url" :folder-upload-time <epoch-time> :gdrive/conn-id <uuid>}}`
;; - on exceptional status, BE returns a message like: "Unable to setup drive folder sync. Please check that the
;; folder is shared with the proper service account email and sharing permissions."
;;
;; ## Polling
;; - FE polls :get "api/ee/gsheets/folder" until `body.status` == `complete`
;; - BE forwards requests to :get "/api/v2/mb/connection/<gdrive-conn-id>", filtering for the google drive connection
;; - If the connection doesn't exist: `{:error "google drive connection not found."}`
;; - If the connection exists and is NOT active, return loading state in the response
;; - If the connection exists and is active AND the latest sync of the attached datawarehouse is AFTER the sync time
;; on Harbormaster's gdrive connection, return `body.status` == "complete".
;; - FE sees status == "complete"
;;
;; ### What does "complete" mean?
;; When the status is "complete", the user can access their google sheets info inside Metabase.
;; This means that both:
;; 1. the gdrive connection has been marked active
;; 2. the attached datawarehouse has completed syncing inside metabase
;; To check this, we compare the last sync time of the attached datawarehouse with the last sync time of the gdrive.
;;
;; ### What if the sync takes too long or never ends?
;; Upon the successful creation of a gdrive connection, we start a timer that will error out if the sync does not
;; complete within [[*folder-setup-timeout-seconds*]] seconds. If the sync does not complete within this window, we
;; reset the gsheets status to `{:status "not-connected"}` and return an error for the FE to show.
;;
;; ## Steps to disconnect a google drive folder
;; - FE sends request to :delete "/folder"
;; - MB loops over all gdrive connection ids and deletes them
;;
;; ## Why do we need to sync the attached datawarehouse before considering it ready?
;; We need to sync the attached datawarehouse to make sure that the data from the Google Drive folder is available to
;; the user in Metabase. Once the gdrive connection's status is set to 'active' by HM, they will call MB's
;; `api/notify/db/attached_datawarehouse` endpoint to trigger a sync. The data from the Google Drive folder is already
;; availiable in the attached datawarehouse, and when MB finishes the sync (and puts an item into :model/TaskHistory
;; saying so), the user can start using their Google Sheets data in Metabase.
;;
;; ## Why do we check for multiple gdrive connections in the delete endpoint? We check for multiple gdrive connections
;; in the delete endpoint to make sure that we are deleting _all_ gdrive connections. As of Milestone 2 there is
;; supposed to be only one gdrive connection, but we observed multiple being created (distributed systems amirite?) and
;; want the delete endpoint to basically reset us to a blank slate. If there are multiple gdrive connections, we will
;; delete all of them. If there are errors during the gdrive conn deletion we ignore them.
(set! *warn-on-reflection* true)
(defn error-response-in-body
"We've decided to return errors as a map with an `:error` key. This function is a helper to make that map.
This formats and throws an ex-info that will put the message into the body of the response."
([message] (error-response-in-body message {}))
([message data]
(throw (ex-info message (merge data {;; the `:errors true` bit informs the exception middleware to return the message
;; in the body, even if we want to pass a stauts code. Without `:errors true`,
;; sending a status code will elide the message from the response.
:errors true
:message message})))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; MB <-> HM APIs
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(mu/defn- hm-service-account-email :- :string
"Checks to see if Google service-account is setup in harbormaster, and returns the email."
[]
(let [[status {:keys [body] :as response}] (hm.client/make-request :get "/api/v2/mb/connections-google/service-account")
email (:email body)]
(cond
email
email
(= :error status)
(error-response-in-body
(tru "Harbormaster returned an error.")
{:hm/response response :status-code 502})
:else
(error-response-in-body
(tru "Google service-account is not setup in harbormaster.")
{:status-code 502}))))
(mr/def :gdrive/connection
[:map {:description "A Harbormaster Gdrive Connection"}
[:id :string]
[:type [:= "gdrive"]]
[:status [:enum "initializing" "syncing" "active" "error"]]
[:last-sync-at [:maybe :time/zoned-date-time]]
[:last-sync-started-at [:maybe :time/zoned-date-time]]
[:created-at :time/zoned-date-time]
[:updated-at :time/zoned-date-time]])
(defn- is-gdrive?
"Is this connection a gdrive connection?"
[{:keys [type] :as _conn}] (= "gdrive" type))
(defn- normalize-gdrive-conn
"Normalize the gdrive connection shape from harbormaster, mostly parsing times."
[gdc]
(-> gdc
(dissoc :hosted-instance-resource)
(m/update-existing :last-sync-at u.date/parse)
(m/update-existing :last-sync-started-at u.date/parse)
(m/update-existing :created-at u.date/parse)
(m/update-existing :updated-at u.date/parse)))
(mu/defn- hm-get-gdrive-conns :- [:sequential :gdrive/connection]
"Get the harbormaster gdrive type connection.
If the response fails: return an empty list.
If the response succeeds: return a list of all gdrive-type connections."
[]
(let [[status response] (hm.client/make-request :get "/api/v2/mb/connections")]
(if (= status :ok)
(mapv normalize-gdrive-conn
(filter is-gdrive? (or (some-> response :body) [])))
[])))
(mu/defn- hm-delete-conn! :- :hm-client/http-reply
"Delete (presumably a gdrive) connection on HM."
[conn-id]
(hm.client/make-request :delete (str "/api/v2/mb/connections/" conn-id)))
(defn- reset-gsheets-status!
"Reset the gsheets status to not-connected and delete all known gdrive connections."
[]
(u/prog1 gsheets.constants/not-connected
(gsheets! <>)
(doseq [{:keys [id]} (hm-get-gdrive-conns)]
(let [[delete-status _] (hm-delete-conn! id)]
(when-not (= delete-status :ok)
(log/debugf "Unable to delete gdrive connection %s." id))))))
(mu/defn hm-get-gdrive-conn :- :hm-client/http-reply
"Get a specific gdrive connection by id."
[id]
(when-not id
(throw (ex-info "Cannot fetch Google Drive connection: ID is nil" {})))
(hm.client/make-request :get (str "/api/v2/mb/connections/" id)))
(mu/defn- hm-create-gdrive-conn! :- :hm-client/http-reply
"Creating a gdrive connection on HM starts the sync w/ drive folder."
[drive-folder-url]
(hm.client/make-request :post "/api/v2/mb/connections" {:type "gdrive" :secret {:resources [drive-folder-url]}}))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; FE <-> MB APIs
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(api.macros/defendpoint :get "/service-account" :- [:map [:email [:maybe :string]]]
"Checks to see if service-account is setup or not, delegates to HM only if we haven't set it from a metabase cluster
before."
[]
(when-not (gsheets.settings/show-google-sheets-integration)
(reset-gsheets-status!)
(error-response-in-body (tru "Google Sheets integration is not enabled.") {:status-code 402}))
{:email (hm-service-account-email)})
(defn- seconds-from-epoch-now
"This is used to track how long a folder has been syncing. We set gsheets.folder-upload-time to this value when we create a
new gdrive connection, and on every :get /folder request we check if the current time is greater than this value plus
[[*folder-setup-timeout-seconds*]]. If it is, that's a timeout."
[] (.getEpochSecond (t/instant)))
(api.macros/defendpoint :post "/folder" :- :gsheets/gsheets
"Hook up a new google drive folder that will be watched and have its content ETL'd into Metabase."
[{} {} {:keys [url]} :- [:map [:url ms/NonBlankString]]]
(let [[status response] (hm-create-gdrive-conn! url)]
(if (= status :ok)
(u/prog1 {:status "loading"
:folder_url url
:folder-upload-time (seconds-from-epoch-now)
:gdrive/conn-id (-> response :body :id)}
(analytics/inc! :metabase-gsheets/connection-creation-began)
(gsheets! <>))
(do
(reset-gsheets-status!)
(error-response-in-body
(tru "Unable to setup drive folder sync.\nPlease check that the folder is shared with the proper service account email and sharing permissions."))))))
(defn- sync-complete? [{:keys [status last-dwh-sync last-gdrive-conn-sync]}]
(and (= status "active") ;; HM says the connection is active
last-dwh-sync ;; make sure it's not nil
last-gdrive-conn-sync ;; make sure it's not nil
;; We finished a sync of the dwh from metabase After the HM conn was synced:
(t/after? (t/instant last-dwh-sync) (t/instant last-gdrive-conn-sync))))
(defn- get-last-mb-dwh-sync-time []
(t2/select-one-fn :ended_at :model/TaskHistory
:db_id (t2/select-one-fn :id [:model/Database :id] :is_attached_dwh true)
:task "sync"
:status :success
{:order-by [[:ended_at :desc]]}))
(def ^:dynamic ^:private *folder-setup-timeout-seconds*
"We want to avoid polling forever, even if harbormaster never finishes the sync, so if the sync does not happen during
this window, we'll error out, reset the gsheets status, and suggest trying again."
(* 10 60))
(defn- handle-get-folder [attached-dwh]
(let [conn-id (or (:gdrive/conn-id (gsheets))
(do (log/warn "CACHE MISS ON GSHEETS")
(some-> (t2/select-one :model/Setting :key "gsheets")
:value
json/decode+kw
:gdrive/conn-id)))
[sstatus {conn :body}] (try (hm-get-gdrive-conn conn-id)
;; missing id:
(catch Exception _
(reset-gsheets-status!)
(error-response-in-body
(tru "Unable to find google drive connection, please try again.")
{:conn-id conn-id})))]
(if (= :ok sstatus)
(let [{:keys [status] last-gdrive-conn-sync :last-sync-at
:as _gdrive-conn} (normalize-gdrive-conn conn)
last-dwh-sync (get-last-mb-dwh-sync-time)]
(-> (cond
(sync-complete? {:status status :last-dwh-sync last-dwh-sync :last-gdrive-conn-sync last-gdrive-conn-sync})
(u/prog1 (assoc (gsheets) :status "complete")
(gsheets! <>)
(analytics/inc! :metabase-gsheets/connection-creation-ok)
(snowplow/track-event! :snowplow/simple_event
{:event "sheets_connected" :event_detail "success"}))
;; Timeout check
(when-let [upload-time (:folder-upload-time (gsheets))]
(> (seconds-from-epoch-now) (+ upload-time *folder-setup-timeout-seconds*)))
(do (reset-gsheets-status!)
(analytics/inc! :metabase-gsheets/connection-creation-error {:reason "timeout"})
(error-response-in-body (tru "Timeout syncing google drive folder, please try again.")
{:status-code 408}))
;; Syncing failed
(= "error" status)
(do
(reset-gsheets-status!)
(analytics/inc! :metabase-gsheets/connection-creation-error {:reason "status_error"})
(error-response-in-body (tru "Problem syncing google drive folder, please try again..")))
;; Continue waiting
:else (gsheets))
(assoc :db_id (:id attached-dwh)
;; here is some debugging info that proved very helpful:
#_#_:hm/conn gdrive-conn
#_#_:mb/sync-info {:status status
:last-dwh-sync last-dwh-sync
:last-gdrive-conn-sync last-gdrive-conn-sync})))
(do
(reset-gsheets-status!)
(error-response-in-body
(tru "Unable to find google drive connection.")
{:status-code 404})))))
(api.macros/defendpoint :get "/folder" :- :gsheets/gsheets
"Check the status of a newly created gsheets folder creation. This endpoint gets polled by FE to determine when to
stop showing the setup widget.
Returns the gsheets shape, with the attached datawarehouse db id at `:db_id`."
[] :- ::gsheets
(let [attached-dwh (t2/select-one :model/Database :is_attached_dwh true)]
(when-not (some? attached-dwh)
(snowplow/track-event! :snowplow/simple_event {:event "sheets_connected" :event_detail "fail - no dwh"})
(reset-gsheets-status!)
(error-response-in-body (tru "No attached dwh found.")))
(handle-get-folder attached-dwh)))
(api.macros/defendpoint :delete "/folder"
"Disconnect the google service account. There is only one (or zero) at the time of writing."
[]
(snowplow/track-event! :snowplow/simple_event {:event "sheets_disconnected"})
(analytics/inc! :metabase-gsheets/connection-deleted)
(reset-gsheets-status!))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/gsheets` routes."
(api/+check-superuser
(api.macros/ns-handler *ns*)))
(comment
;; need an "attached dwh" locally?
(t2/update! :model/Database 1
{:is_attached_dwh true
:settings
(str "{\"auto-cruft-tables\":[\".*_dlt_loads$\",\".*_dlt_pipeline_state$\",\".*_dlt_sentinel_table$\",\".*_dlt_spreadsheet_info$\",\".*_dlt_version$\"],"
"\"auto-cruft-columns\":[\"^_dlt_id$\",\"^_dlt_load_id$\"]}")})
(do
;; This is what the notify endpoint calls to do a sync on the attached dwh:
#_{:clj-kondo/ignore [:metabase/modules]}
(require '[metabase.sync.sync-metadata :as sync-metadata])
(sync-metadata/sync-db-metadata!
(t2/select-one :model/Database :is_attached_dwh true))))

View File

@@ -0,0 +1,5 @@
(ns metabase-enterprise.gsheets.constants)
(def not-connected
"The value of the gsheets setting when the Google Sheets Folder is not setup."
{:status "not-connected"})

View File

@@ -0,0 +1,75 @@
(ns metabase-enterprise.gsheets.settings
(:require
[metabase-enterprise.gsheets.constants :as gsheets.constants]
[metabase.models.setting :as setting :refer [defsetting]]
[metabase.premium-features.core :as premium-features]
[metabase.util :as u]
[metabase.util.i18n :refer [deferred-tru]]
[metabase.util.json :as json]
[metabase.util.malli.registry :as mr]
[metabase.util.malli.schema :as ms]
[toucan2.core :as t2]))
(mr/def :gsheets/gsheets
[:multi {:dispatch :status}
["not-connected" [:map]]
["loading"
[:map
[:folder_url ms/NonBlankString]
;; time in seconds from epoch:
[:folder-upload-time pos-int?]
[:gdrive/conn-id ms/UUIDString]]]
["complete"
[:map
[:folder_url ms/NonBlankString]
;; time in seconds from epoch:
[:folder-upload-time pos-int?]
[:gdrive/conn-id ms/UUIDString]]]])
(defsetting show-google-sheets-integration
"Whether or not to show the user a button that sets up Google Sheets integration."
:visibility :public
:type :boolean
:export? false
:doc "When enabled, we show users a button to authenticate with Google to import data from Google Sheets."
:setter :none
:getter (fn []
(and
(premium-features/is-hosted?)
(premium-features/has-feature? :attached-dwh)
(premium-features/has-feature? :etl-connections)
;; Need to know the store-api-url to make requests to HM
(some? (setting/get :store-api-url))
;; Need [[api-key]] to make requests to HM
(some? (setting/get :api-key)))))
(defsetting gsheets
#_"
This value has 3 states:
1) The Google Sheets Folder is not setup.
{:status \"not-connected\"}
2) We have uploaded a Folder URL to HM, but have not synced it in MB yet.
{:status \"loading\"
:folder_url \"https://drive.google.com/drive/abc\"
:gdrive/conn-id <uuid>
;; in seconds from epoch:
:folder-upload-time 1738266997}
3) Google Sheets Integration is enabled, and users can view their google sheets tables.
{:status \"complete\"
:folder_url \"https://drive.google.com/drive/abc\"}"
(deferred-tru "Information about Google Sheets Integration")
:encryption :when-encryption-key-set
:export? true
:visibility :public
:type :json
:getter (fn [] (or
;; This NEEDS to be up to date between instances on a cluster, so:
;; we are going around the settings cache:
(some-> (t2/select-one :model/Setting :key "gsheets") :value json/decode+kw)
(u/prog1 gsheets.constants/not-connected
(setting/set-value-of-type! :json :gsheets <>)))))

View File

@@ -0,0 +1,108 @@
(ns metabase-enterprise.harbormaster.client
"API client for interfacing with Harbormaster on store-api-url."
(:require
[clj-http.client :as http]
[clojure.string :as str]
[medley.core :as m]
[metabase.api.auth :as api.auth]
[metabase.cloud-migration.core :as cloud-migration]
[metabase.util.i18n :refer [tru]]
[metabase.util.json :as json]
[metabase.util.log :as log]
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr]))
(defn- +slash-prefix [s]
(cond->> s
(not (str/starts-with? s "/")) (str "/")))
(mu/defn- ->requestor [method]
(case method
:get http/get
:head http/head
:post http/post
:put http/put
:delete http/delete
:options http/options
:copy http/copy
:move http/move
:patch http/patch))
(mu/defn- get-safe-status
[response]
(when (number? (:status response))
(http/success? response)))
(defn- ->config
"Returns the config needed to call [[make-request]].
`->config` either gets the store-api-url and api-key from settings or throws an exception when either are unset or
blank."
[]
(let [store-api-url (cloud-migration/store-api-url)
_ (when (str/blank? store-api-url)
(log/error "Missing store-api-url. Cannot create hm client config.")
(throw (ex-info (tru "Missing store-api-url.") {:store-api-url store-api-url})))
api-key (api.auth/api-key)
_ (when (str/blank? api-key)
(log/error "Missing api-key. Cannot create hm client config.")
(throw (ex-info (tru "Missing api-key.") {:api-key api-key})))]
{:store-api-url store-api-url
:api-key api-key}))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PUBLIC API:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(mr/def :hm-client/http-reply [:tuple [:enum :ok :error] :map])
(defn- send-request [request-method-fn store-api-url url request]
(try
(request-method-fn
(str store-api-url (+slash-prefix url))
request)
(catch Exception e
(log/errorf e "Error making request to %s" url)
{:ex-data (ex-data e)
:request request
:url url})))
(defn- decode-respones [unparsed-response url request]
(if (some? (:ex-data unparsed-response)) ;; skip decoding failed responses
unparsed-response
(try
(m/update-existing unparsed-response :body json/decode+kw)
(catch Exception e
(log/errorf e "Error decoding response from %s, is it json?" url)
{:ex-data (ex-data e)
:unparsed-response unparsed-response
:request request
:url url}))))
(defn- calculate-success [response url request]
(try
(get-safe-status response)
(catch Exception e
(log/errorf e "Error decoding response from %s, is it json?" url)
{:response response
:request request
:url url
:ex-data (ex-data e)})))
(mu/defn make-request :- :hm-client/http-reply
"Makes a request to the store-api-url with the given method, path, and body.
Returns a tuple of [:ok response] if the request was successful, or [:error response] if it failed."
[method :- [:enum :get :head :post :put :delete :options :copy :move :patch]
url :- :string
& [body]]
(let [{:keys [store-api-url
api-key]} (->config)
request (cond-> {:headers {"Authorization" (str "Bearer " api-key)
"Content-Type" "application/json"}}
body (assoc :body (json/encode body)))
request-method-fn (->requestor method)
unparsed-response (send-request request-method-fn store-api-url url request)
response (decode-respones unparsed-response url request)
success? (calculate-success response url request)]
[(if success? :ok :error) response]))

View File

@@ -4,16 +4,27 @@
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.models.interface :as mi]
[metabase.premium-features.core :as premium-features]
[metabase.upload :as upload]
[metabase.util.i18n :refer [tru]]
[metabase.util.malli.schema :as ms]
[toucan2.core :as t2]))
(defn- attached-dwh-tables
"Used for adding attached DWH tables to the list of tables visible to the user. In practice these are to manage
google sheets uploads."
[]
(when (premium-features/has-feature? :attached-dwh)
(when-let [dw-db-id (t2/select-one-fn :id :model/Database :is_attached_dwh true)]
(when-let [dw-tables (t2/select :model/Table :db_id dw-db-id :active true)]
dw-tables))))
(api.macros/defendpoint :get "/tables"
"Get all `Tables` visible to the current user which were created by uploading a file."
[]
(as-> (t2/select :model/Table, :active true, :is_upload true, {:order-by [[:name :asc]]}) tables
;; See https://github.com/metabase/metabase/issues/41023
(concat tables (attached-dwh-tables))
(map #(update % :schema str) tables)
(filterv mi/can-read? tables)))

View File

@@ -5,6 +5,7 @@
[metabase-enterprise.advanced-config.file.databases :as advanced-config.file.databases]
[metabase.db :as mdb]
[metabase.driver.h2 :as h2]
[metabase.sync.sync-metadata :as sync-metadata]
[metabase.test :as mt]
[metabase.util :as u]
[toucan2.core :as t2])
@@ -71,6 +72,57 @@
(finally
(t2/delete! :model/Database :name test-db-name))))))
(defn- test-cruft-tables! [crufted-table-setting freq message]
(mt/with-temporary-setting-values [config-from-file-sync-databases nil]
(try
(let [sync-future (@#'advanced-config.file.databases/init-from-config-file!
{:name test-db-name
:engine "h2"
:details (:details (mt/db))
:settings {:auto-cruft-tables crufted-table-setting}})]
(is (future? sync-future))
;; wait for the sync to finish or crash out after 5 seconds
(deref sync-future 5000 :timeout)
(is (= 1 (t2/count :model/Database :name test-db-name)))
(let [db (t2/select-one :model/Database :name test-db-name)
vis-types (t2/select-fn-vec :visibility_type :model/Table :db_id (u/the-id db))]
(is (= freq (frequencies vis-types))
message)))
(finally
(t2/delete! :model/Database :name test-db-name)))))
(deftest sync-cruft-tables-test
(testing "tables marked crufty should be marked as such in the database"
(test-cruft-tables! [] {nil 8} "No tables marked crufty")
(test-cruft-tables! ["^venues$"] {:cruft 1 nil 7} "VENUES table is marked crufty")
(test-cruft-tables! ["."] {:cruft 8} "All tables marked crufty")))
(defn- test-cruft-columns! [crufted-field-setting freq message]
(mt/with-temporary-setting-values [config-from-file-sync-databases nil]
(try
(let [sync-future (@#'advanced-config.file.databases/init-from-config-file!
{:name test-db-name
:engine "h2"
:details (:details (mt/db))
:settings {:auto-cruft-columns crufted-field-setting}})]
(is (future? sync-future))
;; wait for the sync to finish or crash out after 5 seconds
(deref sync-future 5000 :timeout)
(sync-metadata/sync-db-metadata! (t2/select-one :model/Database :name test-db-name))
(is (= 1 (t2/count :model/Database :name test-db-name)))
(let [db (t2/select-one :model/Database :name test-db-name)
tables (t2/select :model/Table :db_id (u/the-id db))
fields (t2/select :model/Field :table_id [:in (map :id tables)])]
(is (= freq (frequencies (map :visibility_type fields)))
message)))
(finally
(t2/delete! :model/Database :name test-db-name)))))
(deftest sync-cruft-columns-test
(testing "columns (aka fields) marked crufty should be marked as such in the database"
(test-cruft-columns! [] {:normal 52} "No fields marked crufty")
(test-cruft-columns! ["id"] {:normal 38, :details-only 14} "All id fields marked crufty")))
(deftest disable-sync-test
(testing "We should be able to disable sync for new Databases by specifying a Setting in the config file"
;; make sure we're actually testing something if it was already set to false locally.

View File

@@ -0,0 +1,211 @@
(ns metabase-enterprise.api.gsheets-test
(:require
[clojure.java.io :as io]
[clojure.test :refer [deftest is testing]]
[java-time.api :as t]
[metabase-enterprise.gsheets.api :as gsheets.api]
[metabase-enterprise.harbormaster.client :as hm.client]
[metabase.test :as mt]
[toucan2.core :as t2])
(:import [java.time
LocalDate
LocalTime
ZoneId
ZonedDateTime]))
(set! *warn-on-reflection* true)
(deftest gsheets-calls-fail-when-missing-etl-connections
(mt/with-temporary-setting-values [api-key "some"]
(mt/with-premium-features #{:attached-dwh}
(is (= (str "ETL Connections is a paid feature not currently available to your instance. "
"Please upgrade to use it. Learn more at metabase.com/upgrade/")
(:message (mt/user-http-request :crowberto :get 402 "ee/gsheets/service-account")))))))
(deftest gsheets-calls-fail-when-not-activated
(mt/with-premium-features #{:etl-connections :attached-dwh}
(mt/with-temporary-setting-values [api-key nil]
(is (partial= {:message "Missing api-key."}
(mt/user-http-request :crowberto :get 500 "ee/gsheets/service-account"))))))
(deftest gsheets-calls-fail-when-missing-attached-dwh
(mt/with-temporary-setting-values [api-key "some"]
(mt/with-premium-features #{:etl-connections}
(is (= (str "Attached DWH is a paid feature not currently available to your instance. "
"Please upgrade to use it. Learn more at metabase.com/upgrade/")
(:message (mt/user-http-request :crowberto :get 402 "ee/gsheets/service-account")))))))
(deftest gsheets-calls-fail-when-non-superuser
(mt/with-premium-features #{:etl-connections :attached-dwh}
(mt/with-temporary-setting-values [api-key nil]
(is (= "You don't have permissions to do that."
(mt/user-http-request :rasta :get 403 "ee/gsheets/service-account"))))))
(deftest gsheets-calls-fail-when-there-is-no-mb-api-key
(mt/with-premium-features #{:etl-connections :attached-dwh :hosting}
(mt/with-temporary-setting-values [api-key nil]
(is (= "Missing api-key."
(:message (mt/user-http-request :crowberto :get 500 "ee/gsheets/service-account")))))))
(def happy-responses (read-string (slurp (io/resource "gsheets/mock_hm_responses.edn"))))
(defn +syncing [responses]
(assoc responses
{:method :get, :url "/api/v2/mb/connections", :body nil}
[:ok
{:status 200,
:body [{:updated-at "2025-01-27T18:43:04Z",
:hosted-instance-resource-id 7,
:last-sync-at nil,
:error-detail nil,
:type "gdrive",
:hosted-instance-id "f390ec19-bd44-48ae-991c-66817182a376",
:last-sync-started-at "2025-01-27T18:43:04Z",
:status "syncing",
:id "049f3007-2146-4083-be38-f160c526aca7",
:created-at "2025-01-27T18:43:02Z"}]}]))
(defn mock-make-request
([responses method url] (mock-make-request responses method url nil))
([responses method url body]
(get responses {:method method :url url :body body}
[:error {:status 404}])))
(deftest gsheets-calls-pass-when-activated-and-superuser
(mt/with-premium-features #{:etl-connections :attached-dwh :hosting}
(mt/with-temporary-setting-values [api-key "non-blank"]
(with-redefs [hm.client/make-request (partial mock-make-request happy-responses)]
(is (malli=
[:map [:email [:maybe :string]]]
(mt/user-http-request :crowberto :get 200 "ee/gsheets/service-account")))))))
(defn- ->zdt
[^long date ^long time ^String zone]
(ZonedDateTime/of (LocalDate/of date 1 1) (-> LocalTime/MIDNIGHT (.plusSeconds time)) (ZoneId/of zone)))
(deftest sync-complete?-test
(let [earlier-time (->zdt 2000 0 "UTC")
later-time (->zdt 2022 0 "UTC")]
(is (not (#'gsheets.api/sync-complete? {:status "initializing" :last-dwh-sync nil :last-gdrive-conn-sync nil}))
"status must be active for sync to be complete")
(is (not (#'gsheets.api/sync-complete? {:status "active" :last-dwh-sync nil :last-gdrive-conn-sync nil}))
"sync is not complete when we don't get a last-gdrive-conn-sync time")
(is (not (#'gsheets.api/sync-complete? {:status "active" :last-dwh-sync nil :last-gdrive-conn-sync earlier-time}))
"sync is not complete when we don't get a last-dwh-sync time")
(is (not (#'gsheets.api/sync-complete? {:status "active" :last-dwh-sync later-time :last-gdrive-conn-sync nil}))
"sync is not complete when we don't get a last-gdrive-conn-sync time")
(is (not (#'gsheets.api/sync-complete? {:status "active" :last-dwh-sync earlier-time :last-gdrive-conn-sync later-time}))
"sync is not complete when the last dwh sync is before the last gdrive conn sync")
(is (not (#'gsheets.api/sync-complete? {:status "active" :last-dwh-sync later-time :last-gdrive-conn-sync later-time}))
"sync is not complete when the last dwh sync == the last gdrive conn sync")
(is (#'gsheets.api/sync-complete? {:status "active" :last-dwh-sync later-time :last-gdrive-conn-sync earlier-time})
"sync is complete when we get active status and the last local sync time is before current time")))
(deftest can-get-service-account-test
(let [[status response] (mock-make-request happy-responses
:get
"/api/v2/mb/connections-google/service-account")]
(is (= :ok status))
(is (malli= [:map [:email [:string {:min 1}]]] (:body response)))))
(def ^:private
gdrive-link
"nb: if you change this, change it in test_resources/gsheets/mock_hm_responses.edn"
"<expected-gdrive-link>")
(defmacro with-sample-db-as-dwh [& body]
"We need an attached dwh for these tests, so let's have the sample db fill in for us:"
`(try
(t2/update! :model/Database :id 1 {:is_attached_dwh true})
~@body
(finally (t2/update! :model/Database :id 1 {:is_attached_dwh false}))))
(deftest post-folder-test
(with-sample-db-as-dwh
(mt/with-premium-features #{:etl-connections :attached-dwh :hosting}
(with-redefs [hm.client/make-request (partial mock-make-request happy-responses)]
(is (partial=
{:status "loading", :folder_url gdrive-link}
(mt/user-http-request :crowberto :post 200 "ee/gsheets/folder" {:url gdrive-link})))))))
(deftest post-folder-syncing-test
(mt/with-premium-features #{:etl-connections :attached-dwh :hosting}
(with-redefs [hm.client/make-request (partial mock-make-request (+syncing happy-responses))]
(is (partial= {:status "loading", :folder_url gdrive-link}
(mt/user-http-request :crowberto :post 200 "ee/gsheets/folder" {:url gdrive-link}))))))
(deftest get-folder-test
(with-sample-db-as-dwh
(mt/with-premium-features #{:etl-connections :attached-dwh :hosting}
;; This puts us into loading state:
(with-redefs [hm.client/make-request (partial mock-make-request (+syncing happy-responses))]
(mt/user-http-request :crowberto :post 200 "ee/gsheets/folder" {:url gdrive-link}))
(with-redefs [hm.client/make-request (partial mock-make-request happy-responses)]
(dotimes [_ 10]
(with-redefs [gsheets.api/get-last-mb-dwh-sync-time (constantly nil)]
(testing (str "when the dwh has never been synced, we should be status=loading.\n"
"calling it over and over will return the same result.")
(is (partial= {:status "loading", :folder_url gdrive-link :db_id 1}
(mt/user-http-request :crowberto :get 200 "ee/gsheets/folder"))))
(mt/user-http-request :crowberto :get 200 "ee/gsheets/folder")))
(testing "when the local sync time is before the last gdrive connection sync time, we should be status=loading."
(with-redefs [gsheets.api/get-last-mb-dwh-sync-time (constantly (t/instant "2000-01-01T00:00:00Z"))]
(is (partial= {:status "loading", :folder_url gdrive-link :db_id 1}
(mt/user-http-request :crowberto :get 200 "ee/gsheets/folder")))
(mt/user-http-request :crowberto :get 200 "ee/gsheets/folder")))
(testing "when the local sync time is after the last gdrive connection sync time, then we should be status=complete."
(with-redefs [gsheets.api/get-last-mb-dwh-sync-time (constantly (t/instant "2222-01-01T00:00:00Z"))]
(is (partial= {:status "complete" :folder_url gdrive-link :db_id 1}
(mt/user-http-request :crowberto :get 200 "ee/gsheets/folder")))))))))
(deftest get-folder-timeout-test
(with-sample-db-as-dwh
(mt/with-premium-features #{:etl-connections :attached-dwh :hosting}
(with-redefs [hm.client/make-request (partial mock-make-request (+syncing happy-responses))]
(let [resp (mt/user-http-request :crowberto :post 200 "ee/gsheets/folder" {:url gdrive-link})]
(with-redefs [gsheets.api/get-last-mb-dwh-sync-time (constantly nil)
gsheets.api/seconds-from-epoch-now (constantly
;; set "now" to 1 second after now + folder upload time:
(+ 1 @#'gsheets.api/*folder-setup-timeout-seconds*
(:folder-upload-time resp)))]
(is (= {:errors true, :message "Timeout syncing google drive folder, please try again."}
(mt/user-http-request :crowberto :get 408 "ee/gsheets/folder"))
"When we timeout, we should return an error.")))))))
(deftest delete-folder-test
(with-sample-db-as-dwh
(mt/with-premium-features #{:etl-connections :attached-dwh :hosting}
(with-redefs [hm.client/make-request (partial mock-make-request happy-responses)]
(is (= {:status "not-connected"}
(mt/user-http-request :crowberto :delete 200 "ee/gsheets/folder")))))))
(defn +empty-conn-listing [responses]
(assoc responses {:method :get, :url "/api/v2/mb/connections", :body nil}
[:ok {:status 200,
:body []}]))
(deftest delete-folder-cannot-find
(with-sample-db-as-dwh
(mt/with-premium-features #{:etl-connections :attached-dwh :hosting}
(with-redefs [hm.client/make-request (partial mock-make-request (+empty-conn-listing happy-responses))]
(is (= {:status "not-connected"}
(mt/user-http-request :crowberto :delete 200 "ee/gsheets/folder")))))))
(defn +failed-delete-response [responses]
(assoc responses
{:method :delete, :url "/api/v2/mb/connections/049f3007-2146-4083-be38-f160c526aca7", :body nil}
[:error {}]))
(deftest delete-folder-fail
(with-sample-db-as-dwh
(mt/with-premium-features #{:etl-connections :attached-dwh :hosting}
(with-redefs [hm.client/make-request (partial mock-make-request (+failed-delete-response happy-responses))]
(= {:status "not-connected"}
(mt/user-http-request :crowberto :delete 200 "ee/gsheets/folder"))))))

View File

@@ -0,0 +1,34 @@
(ns metabase-enterprise.harbormaster.client-test
(:require
[clojure.test :refer [deftest is testing]]
[metabase-enterprise.harbormaster.client :as hm.client]
[metabase.models.setting :as setting]
[metabase.test.util :as mt]))
(deftest ->config-good-test
(testing "Both needed values are present and pulled from settings"
(mt/with-temporary-setting-values
[api-key "mb_api_key_123"
store-api-url "http://store-api-url.com"]
(is (= {:store-api-url "http://store-api-url.com", :api-key "mb_api_key_123"}
(#'hm.client/->config))))))
(defn- +value-for-setting [grv setting-kw value]
(fn
([k] (grv k))
([k pred parse-fn]
(if (= k setting-kw) value (grv k pred parse-fn)))))
(deftest ->config-blank-api-key-test
(let [grv setting/get-raw-value]
;; mt/with-temporary-setting-values is not used here because we want to test the behavior of the function when the
;; api-key is blank or nil, and mt/with-temporary-setting-values will not allow us to set the api-key to blank
;; or nil.
(with-redefs [setting/get-raw-value (+value-for-setting grv :api-key "")]
(is (thrown-with-msg? Exception
#"Missing api-key."
(#'hm.client/->config))))
(with-redefs [setting/get-raw-value (+value-for-setting grv :api-key nil)]
(is (thrown-with-msg? Exception
#"Missing api-key."
(#'hm.client/->config))))))

View File

@@ -0,0 +1,46 @@
import type { DatabaseId, Settings } from "metabase-types/api";
import { EnterpriseApi } from "./api";
export const gsheetsApi = EnterpriseApi.injectEndpoints({
endpoints: builder => ({
getServiceAccount: builder.query<{ email: string }, void>({
query: () => ({
method: "GET",
url: "/api/ee/gsheets/service-account",
}),
}),
getGsheetsFolder: builder.query<
Settings["gsheets"] & { db_id: DatabaseId },
void
>({
query: () => ({
method: "GET",
url: "/api/ee/gsheets/folder",
}),
}),
saveGsheetsFolderLink: builder.mutation<
{ success: boolean },
{ url: string }
>({
query: body => ({
method: "POST",
url: "/api/ee/gsheets/folder",
body: body,
}),
}),
deleteGsheetsFolderLink: builder.mutation<{ success: boolean }, void>({
query: () => ({
method: "DELETE",
url: "/api/ee/gsheets/folder",
}),
}),
}),
});
export const {
useGetServiceAccountQuery,
useGetGsheetsFolderQuery,
useDeleteGsheetsFolderLinkMutation,
useSaveGsheetsFolderLinkMutation,
} = gsheetsApi;

View File

@@ -1,5 +1,6 @@
export * from "./audit-info";
export * from "./billing-info";
export * from "./gsheets";
export * from "./query-validation";
export * from "./scim";
export * from "./upload-management";

View File

@@ -0,0 +1,403 @@
import { type FormEvent, useState } from "react";
import { useLocation } from "react-use";
import { match } from "ts-pattern";
import { jt, t } from "ttag";
import { reloadSettings } from "metabase/admin/settings/settings";
import { skipToken, useGetDatabaseQuery } from "metabase/api";
import { useSetting } from "metabase/common/hooks";
import { CopyButton } from "metabase/components/CopyButton";
import { useDispatch, useSelector } from "metabase/lib/redux";
import { getUserIsAdmin } from "metabase/selectors/user";
import {
Box,
Button,
Center,
Flex,
Icon,
Menu,
Modal,
Stack,
Text,
TextInput,
} from "metabase/ui";
import {
useDeleteGsheetsFolderLinkMutation,
useGetServiceAccountQuery,
useSaveGsheetsFolderLinkMutation,
} from "metabase-enterprise/api";
import { trackSheetConnectionClick, trackSheetImportClick } from "./analytics";
import disconnectIllustration from "./disconnect.svg?component";
export function GsheetConnectButton() {
const url = useLocation();
const databaseId = /databases\/(\d+)/.exec(url.pathname ?? "")?.[1];
const { data: databaseInfo } = useGetDatabaseQuery(
databaseId ? { id: Number(databaseId) } : skipToken,
);
const isDwh = databaseInfo?.is_attached_dwh;
const [showModal, setShowModal] = useState(false);
const gSheetsSetting = useSetting("gsheets");
const gSheetsEnabled = useSetting("show-google-sheets-integration");
const userIsAdmin = useSelector(getUserIsAdmin);
const shouldGetServiceAccount = isDwh && gSheetsEnabled && userIsAdmin;
const { data: { email: serviceAccountEmail } = {} } =
useGetServiceAccountQuery(shouldGetServiceAccount ? undefined : skipToken);
const { status } = gSheetsSetting;
if (
!gSheetsEnabled ||
!gSheetsSetting ||
!userIsAdmin ||
!serviceAccountEmail ||
!isDwh
) {
return null;
}
const buttonText = match(status)
.with("not-connected", () => t`Connect Google Sheets`)
.with("loading", () => t`Google Sheets connecting...`)
.with("complete", () => t`Disconnect`)
.otherwise(() => t`Google Sheets`);
return (
<Flex align="center" gap="sm">
{status === "complete" && (
<Flex align="center" gap="xs">
<Icon name="google_sheet" />
<Text>{t`Connected to Google Sheets`}</Text>
</Flex>
)}
<Text>{" · "}</Text>
<Button
p={0}
variant="subtle"
onClick={() => {
setShowModal(true);
trackSheetConnectionClick({ from: "db-page" });
}}
disabled={status === "loading"}
leftSection={
status === "complete" ? undefined : <Icon name="google_sheet" />
}
>
{buttonText}
</Button>
<GsheetConnectionModal
isModalOpen={showModal}
onClose={() => setShowModal(false)}
reconnect={false}
/>
</Flex>
);
}
export function GsheetMenuItem({ onClick }: { onClick: () => void }) {
const gSheetsSetting = useSetting("gsheets");
const gSheetsEnabled = useSetting("show-google-sheets-integration");
const { status } = gSheetsSetting;
const userIsAdmin = useSelector(getUserIsAdmin);
const { data: { email: serviceAccountEmail } = {} } =
useGetServiceAccountQuery();
if (
!gSheetsEnabled ||
!gSheetsSetting ||
!userIsAdmin ||
!serviceAccountEmail
) {
return null;
}
const handleClick = () => {
trackSheetConnectionClick({ from: "left-nav" });
onClick();
};
const buttonText = match(status)
.with("not-connected", () => t`Connect Google Sheets`)
.with("loading", () => t`Google Sheets`)
.with("complete", () => t`Google Sheets`)
.otherwise(() => t`Google Sheets`);
const helperText = match(status)
.with("not-connected", () => null)
.with("loading", () => t`Importing files...`)
.with("complete", () => t`Connected`)
.otherwise(() => null);
return (
<Menu.Item onClick={handleClick} disabled={status === "loading"}>
<Flex gap="sm" align="flex-start" justify="space-between" w="100%">
<Flex>
<Icon name="google_sheet" mt="xs" mr="sm" />
<div>
<Text
fw="bold"
c={status === "loading" ? "text-light" : "text-dark"}
>
{buttonText}
</Text>
{helperText && (
<Text size="sm" c="text-medium">
{helperText}
</Text>
)}
</div>
</Flex>
{status === "complete" && (
<Button variant="subtle" onClick={handleClick}>
{t`Add New`}
</Button>
)}
</Flex>
</Menu.Item>
);
}
export function GsheetConnectionModal({
isModalOpen,
onClose,
reconnect,
}: {
isModalOpen: boolean;
onClose: () => void;
reconnect: boolean;
}) {
const gSheetsSetting = useSetting("gsheets");
const userIsAdmin = useSelector(getUserIsAdmin);
const { data: { email: serviceAccountEmail } = {} } =
useGetServiceAccountQuery();
const gSheetsEnabled = useSetting("show-google-sheets-integration");
if (
!gSheetsEnabled ||
!gSheetsSetting ||
!userIsAdmin ||
!serviceAccountEmail
) {
return null;
}
const { status, folder_url } = gSheetsSetting;
return (
isModalOpen &&
(status === "not-connected" ? (
<GoogleSheetsConnectModal
onClose={onClose}
serviceAccountEmail={serviceAccountEmail ?? "email not found"}
folderUrl={folder_url}
/>
) : (
<GoogleSheetsDisconnectModal onClose={onClose} reconnect={reconnect} />
))
);
}
const ModalWrapper = ({
children,
onClose,
}: {
children: React.ReactNode;
onClose: () => void;
}) => (
<Modal opened onClose={onClose} size="lg">
<Flex px="lg" pb="lg" gap="md" direction="column">
{children}
</Flex>
</Modal>
);
function GoogleSheetsConnectModal({
onClose,
folderUrl,
serviceAccountEmail,
}: {
onClose: () => void;
folderUrl: string | null;
serviceAccountEmail: string;
}) {
const dispatch = useDispatch();
const [folderLink, setFolderLink] = useState(folderUrl ?? "");
const [errorMessage, setErrorMessage] = useState("");
const [saveFolderLink, { isLoading: isSavingFolderLink }] =
useSaveGsheetsFolderLinkMutation();
const onSave = async (event: FormEvent) => {
event.preventDefault();
setErrorMessage("");
const validationRegex = /(https|http)\:\/\/drive\.google\.com\/.+/;
if (!validationRegex.test(folderLink.trim())) {
setErrorMessage(t`Invalid Google Drive folder link`);
return;
}
trackSheetImportClick();
await saveFolderLink({
url: folderLink.trim(),
})
.unwrap()
.then(() => {
dispatch(reloadSettings());
onClose();
})
.catch(response => {
setErrorMessage(response?.data?.message ?? "Something went wrong");
});
};
return (
<ModalWrapper onClose={onClose}>
<Text size="lg" fw="bold">
{
// eslint-disable-next-line no-literal-metabase-strings -- admin only string
t`Share the Google Drive folder that contains your Google Sheets with Metabase`
}
</Text>
<Flex
bg="bg-light"
style={{ borderRadius: "0.5rem" }}
p="md"
direction="column"
gap="md"
>
<Box>
<Text>
1. {t`In Google Drive, right-click on the folder → Share`}
</Text>
</Box>
<Flex align="center" justify="space-between">
<Text>
2.{" "}
{jt`Enter: ${(<strong>{serviceAccountEmail ?? t`Error fetching service account email`}</strong>)}`}
</Text>
<CopyButton value={serviceAccountEmail}></CopyButton>
</Flex>
<Box>
<Text>
3.{" "}
{jt`Select ${(<strong>{t`Viewer`}</strong>)} permissions, and click on ${(<strong>{t`Send`}</strong>)}`}
</Text>
</Box>
</Flex>
<form onSubmit={onSave}>
<Box>
<Text
size="lg"
fw="bold"
>{t`Paste the sharing link for the folder`}</Text>
<TextInput
my="sm"
disabled={isSavingFolderLink}
value={folderLink}
onChange={e => setFolderLink(e.target.value)}
placeholder="https://drive.google.com/drive/folders/abc123-xyz456"
/>
<Text
size="sm"
color="secondary"
>{t`In Google Drive, right-click on the folder → Share → Copy link`}</Text>
</Box>
<Flex justify="space-between" align="center" mt="sm" gap="md">
<Text c="error" lh="1.2rem">
{errorMessage}
</Text>
<Button
type="submit"
variant="filled"
loading={isSavingFolderLink}
disabled={folderLink.length < 3}
style={{ flexShrink: 0 }}
>
{t`Import Google Sheets`}
</Button>
</Flex>
</form>
</ModalWrapper>
);
}
function GoogleSheetsDisconnectModal({
onClose,
reconnect = false,
}: {
onClose: () => void;
reconnect: boolean;
}) {
const [errorMessage, setErrorMessage] = useState("");
const dispatch = useDispatch();
const [deleteFolderLink, { isLoading: isDeletingFolderLink }] =
useDeleteGsheetsFolderLinkMutation();
const onDelete = async () => {
setErrorMessage("");
await deleteFolderLink()
.unwrap()
.then(() => {
dispatch(reloadSettings());
if (!reconnect) {
onClose();
}
})
.catch(response => {
setErrorMessage(response?.data?.message ?? "Something went wrong");
});
};
return (
<ModalWrapper onClose={onClose}>
<Flex justify="center" align="center" direction="column" gap="md" p="xl">
<Center p="md">
<Box component={disconnectIllustration} />
</Center>
<Text size="lg" fw="bold">
{reconnect
? t`To add a new Google Drive folder, the existing one needs to be disconnected first.`
: t`Disconnect from Google Drive?`}
</Text>
<Text>
{reconnect
? // eslint-disable-next-line no-literal-metabase-strings -- admin only string
t`Only one folder can be synced with Metabase at a time. Your tables and Google Sheets will remain in place.`
: t`Your existing tables and Google Sheets will remain in place but they will no longer be updated automatically.`}
</Text>
<Stack mt="sm">
<Stack w="13rem" mx="auto">
<Button
fullWidth
variant="filled"
color="danger"
loading={isDeletingFolderLink}
onClick={onDelete}
>
{t`Disconnect`}
</Button>
<Button fullWidth variant="outline" onClick={onClose}>
{t`Keep connected`}
</Button>
</Stack>
<Text c="error" ta="center">
{errorMessage}
</Text>
</Stack>
</Flex>
</ModalWrapper>
);
}

View File

@@ -0,0 +1,160 @@
import { useEffect, useState } from "react";
import { usePrevious } from "react-use";
import { match } from "ts-pattern";
import { t } from "ttag";
import { reloadSettings } from "metabase/admin/settings/settings";
import { skipToken } from "metabase/api";
import { useSetting } from "metabase/common/hooks";
import { useDispatch, useSelector } from "metabase/lib/redux";
import { getUserIsAdmin } from "metabase/selectors/user";
import StatusLarge from "metabase/status/components/StatusLarge";
import { useGetGsheetsFolderQuery } from "metabase-enterprise/api/gsheets";
import type { DatabaseId, Settings } from "metabase-types/api";
type GsheetsStatus = Settings["gsheets"]["status"];
type ErrorPayload = { data?: { message: string } };
export const GsheetsSyncStatus = () => {
const gsheetsSetting = useSetting("gsheets");
const { status: settingStatus } = gsheetsSetting;
const previousSettingStatus = usePrevious(settingStatus);
const dispatch = useDispatch();
const isAdmin = useSelector(getUserIsAdmin);
const [forceHide, setForceHide] = useState(
!isAdmin || settingStatus !== "loading",
);
const [syncError, setSyncError] = useState({ error: false, message: "" });
const [dbId, setDbId] = useState<DatabaseId | undefined>();
const shouldPoll = isAdmin && settingStatus === "loading";
const { currentData: folderSync, error: apiError } = useGetGsheetsFolderQuery(
shouldPoll ? undefined : skipToken,
{ pollingInterval: 3000 },
);
// if our polling endpoint changes away from loading, refresh the settings and show error, if any
useEffect(() => {
if (folderSync?.status !== "loading" || apiError) {
if (apiError) {
console.error((apiError as ErrorPayload)?.data?.message);
setSyncError({
error: true,
// eslint-disable-next-line no-literal-metabase-strings -- admin UI
message: t`Please check that the folder is shared with the Metabase Service Account.`,
});
}
if (folderSync?.db_id) {
setDbId(folderSync.db_id);
}
dispatch(reloadSettings());
}
}, [folderSync, dispatch, settingStatus, apiError]);
useEffect(() => {
// if our setting changed to loading from something else, reset the force hide and clear any errors
if (settingStatus === "loading" && previousSettingStatus !== "loading") {
setForceHide(false);
setSyncError({
error: false,
message: "",
});
}
// if our setting changed to not-connected from loading and we don't have an error, force hide
if (
settingStatus === "not-connected" &&
previousSettingStatus === "loading" &&
!syncError.error
) {
setForceHide(true);
}
}, [settingStatus, previousSettingStatus, syncError]);
if (forceHide || !isAdmin) {
return null;
}
const displayStatus = match({
folderSyncStatus: folderSync?.status,
folderSyncError: syncError.error,
settingStatus,
})
.returnType<GsheetsStatus>()
.with({ folderSyncError: true }, () => "error")
.with({ folderSyncStatus: "complete" }, () => "complete")
.with({ settingStatus: "complete" }, () => "complete")
.with({ folderSyncStatus: "loading" }, () => "loading")
.with({ settingStatus: "loading" }, () => "loading")
.otherwise(() => "loading");
return (
<GsheetsSyncStatusView
status={displayStatus}
db_id={dbId}
error={syncError.message ?? ""}
onClose={() => setForceHide(true)}
/>
);
};
function GsheetsSyncStatusView({
status,
db_id,
error,
onClose,
}: {
status: GsheetsStatus;
db_id?: DatabaseId;
error?: string;
onClose: () => void;
}) {
const title = match(status)
.with("complete", () => t`Imported Google Sheets`)
.with("error", () => t`Error importing Google Sheets`)
.otherwise(() => t`Importing Google Sheets...`);
const itemTitle = match(status)
.with("complete", () => t`Start exploring`)
.otherwise(() => t`Google Sheets`);
if (error) {
console.error(error);
}
const description = match(status)
.with("complete", () => t`Files sync every 15 minutes`)
.with(
"error",
() =>
// eslint-disable-next-line no-literal-metabase-strings -- admin UI
t`Please check that the folder is shared with the Metabase Service Account.`,
)
.otherwise(() => undefined);
return (
<StatusLarge
status={{
title,
items: [
{
title: itemTitle,
href:
status === "complete" ? `/browse/databases/${db_id}` : undefined,
icon: "google_drive",
description,
isInProgress: status === "loading",
isCompleted: status === "complete",
isAborted: status === "error",
},
],
}}
isActive
onDismiss={onClose}
/>
);
}

View File

@@ -0,0 +1,208 @@
import userEvent from "@testing-library/user-event";
import {
setupGsheetsGetFolderEndpoint,
setupPropertiesEndpoints,
setupSettingsEndpoints,
} from "__support__/server-mocks";
import { renderWithProviders, screen, waitFor } from "__support__/ui";
import { reloadSettings } from "metabase/admin/settings/settings";
import { useDispatch } from "metabase/lib/redux";
import type { Settings } from "metabase-types/api";
import { createMockSettings, createMockUser } from "metabase-types/api/mocks";
import { createMockSettingsState } from "metabase-types/store/mocks";
import { GsheetsSyncStatus } from "./GsheetsSyncStatus";
type GsheetsStatus = Settings["gsheets"]["status"];
function TestComponent() {
const dispatch = useDispatch();
return (
<>
<GsheetsSyncStatus />
<button onClick={() => dispatch(reloadSettings())}>
Test Settings Update
</button>
</>
);
}
const setup = ({
settingStatus,
updatedSettingStatus,
folderStatus,
isAdmin = true,
errorCode,
}: {
settingStatus: GsheetsStatus;
updatedSettingStatus?: GsheetsStatus;
folderStatus: GsheetsStatus;
isAdmin?: boolean;
errorCode?: number;
}) => {
const updatedSettings = createMockSettings({
gsheets: {
status: updatedSettingStatus ?? settingStatus,
folder_url: null,
},
});
setupPropertiesEndpoints(updatedSettings);
setupSettingsEndpoints([]);
errorCode
? setupGsheetsGetFolderEndpoint({ errorCode })
: setupGsheetsGetFolderEndpoint({ status: folderStatus });
return renderWithProviders(<TestComponent />, {
storeInitialState: {
settings: createMockSettingsState({
gsheets: {
status: settingStatus,
folder_url: null,
},
}),
currentUser: createMockUser({ is_superuser: isAdmin }),
},
});
};
describe("GsheetsSyncStatus", () => {
it("should not render anything in not-connected state", () => {
setup({
settingStatus: "not-connected",
folderStatus: "not-connected",
});
expect(screen.queryByText(/Google/i)).not.toBeInTheDocument();
});
it("should appear when status changes from not-connected to loading", async () => {
setup({
settingStatus: "not-connected",
updatedSettingStatus: "loading",
folderStatus: "loading",
});
// initial state
expect(screen.queryByText(/Google/i)).not.toBeInTheDocument();
// trigger settings update
userEvent.click(await screen.findByText("Test Settings Update"));
// loading state
expect(
await screen.findByText("Importing Google Sheets..."),
).toBeInTheDocument();
});
it("should not render anything for non-admins", () => {
setup({
settingStatus: "loading",
folderStatus: "loading",
isAdmin: false,
});
expect(screen.queryByText(/Google/i)).not.toBeInTheDocument();
});
it("should not render anything when initial state is complete", () => {
setup({
settingStatus: "complete",
folderStatus: "complete",
});
expect(screen.queryByText(/Google/i)).not.toBeInTheDocument();
});
it("should render loading state", () => {
setup({
settingStatus: "loading",
folderStatus: "loading",
});
expect(screen.getByText("Importing Google Sheets...")).toBeInTheDocument();
});
it("should close when the X is clicked", async () => {
setup({
settingStatus: "loading",
folderStatus: "loading",
});
expect(screen.getByText("Importing Google Sheets...")).toBeInTheDocument();
userEvent.click(await screen.findByLabelText("Dismiss"));
await waitFor(() =>
expect(screen.queryByText(/Google/i)).not.toBeInTheDocument(),
);
});
it("should render completed state after initial status is loading", async () => {
setup({
settingStatus: "loading",
folderStatus: "complete",
});
// initial loading state
expect(screen.getByText("Importing Google Sheets...")).toBeInTheDocument();
// complete state
expect(
await screen.findByText("Imported Google Sheets"),
).toBeInTheDocument();
screen.getByText("Start exploring");
screen.getByText("Files sync every 15 minutes");
});
it("should show errors", async () => {
setup({
settingStatus: "loading",
folderStatus: "loading",
errorCode: 500,
});
// initial loading state
expect(screen.getByText("Importing Google Sheets...")).toBeInTheDocument();
// error state
expect(
await screen.findByText(/Error importing Google Sheets/),
).toBeInTheDocument();
});
it("should clear error if the user tries to connect again", async () => {
setup({
settingStatus: "loading",
folderStatus: "not-connected",
errorCode: 500,
updatedSettingStatus: "not-connected",
});
// initial loading state
expect(screen.getByText("Importing Google Sheets...")).toBeInTheDocument();
// not-connected state
await waitFor(() =>
expect(screen.queryByText(/Google/i)).not.toBeInTheDocument(),
);
});
it("should disappear if state changes from loading to not-connected without an error", async () => {
setup({
settingStatus: "loading",
folderStatus: "not-connected",
updatedSettingStatus: "not-connected",
});
// initial loading state
expect(screen.getByText("Importing Google Sheets...")).toBeInTheDocument();
// not-connected state
await waitFor(() =>
expect(screen.queryByText(/Google/i)).not.toBeInTheDocument(),
);
});
});

View File

@@ -0,0 +1,20 @@
import { trackSimpleEvent } from "metabase/lib/analytics";
import type { GsheetsConnectionClickedEvent } from "metabase-types/analytics";
export function trackSheetImportClick() {
trackSimpleEvent({
event: "sheets_import_by_url_clicked",
triggered_from: "sheets-url-popup",
});
}
export function trackSheetConnectionClick({
from,
}: {
from: GsheetsConnectionClickedEvent["triggered_from"];
}) {
trackSimpleEvent({
event: "sheets_connection_clicked",
triggered_from: from,
});
}

View File

@@ -0,0 +1,8 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="96" height="96" rx="48" fill="#071722" fill-opacity="0.02"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M34 33.625C32.6883 33.625 31.625 34.6883 31.625 36V59.5284C31.625 61.3924 33.136 62.9034 35 62.9034H61.8361C63.7001 62.9034 65.2111 61.3924 65.2111 59.5284V41.1371C65.2111 39.8254 64.1478 38.7621 62.8361 38.7621H49.6421C49.4764 38.7621 49.3174 38.6963 49.2002 38.5791L44.2461 33.625H34ZM30.375 36C30.375 33.998 31.998 32.375 34 32.375H44.505C44.6708 32.375 44.8298 32.4408 44.947 32.5581L49.901 37.5121H62.8361C64.8382 37.5121 66.4611 39.1351 66.4611 41.1371V59.5284C66.4611 62.0827 64.3904 64.1534 61.8361 64.1534H35C32.4457 64.1534 30.375 62.0827 30.375 59.5284V36Z" fill="#92999E"/>
<path d="M35.5504 46.1558C35.8961 44.8363 37.0884 43.916 38.4525 43.916H68.4087C69.7191 43.916 70.6754 45.1552 70.3434 46.4228L66.4496 61.2882C66.1039 62.6078 64.9116 63.5281 63.5475 63.5281H34.887C32.9214 63.5281 31.4869 61.6694 31.9849 59.7679L35.5504 46.1558Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M34.9458 45.9975C35.3635 44.403 36.8042 43.291 38.4525 43.291H68.4086C70.1285 43.291 71.3838 44.9174 70.948 46.5812L67.0541 61.4466C66.6365 63.0411 65.1957 64.1531 63.5475 64.1531H34.887C32.5119 64.1531 30.7785 61.9071 31.3803 59.6095L34.9458 45.9975ZM38.4525 44.541C37.3726 44.541 36.4286 45.2696 36.155 46.3142L32.5895 59.9263C32.1952 61.4316 33.3309 62.9031 34.887 62.9031H63.5475C64.6274 62.9031 65.5713 62.1745 65.8449 61.1299L69.7388 46.2644C69.967 45.3929 69.3095 44.541 68.4086 44.541H38.4525Z" fill="#92999E"/>
<circle cx="66" cy="60" r="9" fill="white" stroke="white" stroke-width="2"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M72 60C72 63.3137 69.3137 66 66 66C64.7042 66 63.5044 65.5892 62.5236 64.8908L70.8908 56.5236C71.5892 57.5044 72 58.7042 72 60ZM61.1093 63.4766L69.4766 55.1093C68.4958 54.4108 67.2959 54 66 54C62.6863 54 60 56.6863 60 60C60 61.2959 60.4108 62.4958 61.1093 63.4766ZM74 60C74 64.4183 70.4183 68 66 68C61.5817 68 58 64.4183 58 60C58 55.5817 61.5817 52 66 52C70.4183 52 74 55.5817 74 60Z" fill="var(--mb-color-error)"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,2 @@
export * from "./GoogleSheetManagement";
export * from "./GsheetsSyncStatus";

View File

@@ -1,8 +1,22 @@
import { PLUGIN_UPLOAD_MANAGEMENT } from "metabase/plugins";
import { hasPremiumFeature } from "metabase-enterprise/settings";
import {
GsheetConnectButton,
GsheetConnectionModal,
GsheetMenuItem,
GsheetsSyncStatus,
} from "../google_sheets";
import { UploadManagementTable } from "./UploadManagementTable";
if (hasPremiumFeature("upload_management")) {
PLUGIN_UPLOAD_MANAGEMENT.UploadManagementTable = UploadManagementTable;
}
if (hasPremiumFeature("hosting") && hasPremiumFeature("attached_dwh")) {
PLUGIN_UPLOAD_MANAGEMENT.GsheetConnectionModal = GsheetConnectionModal;
PLUGIN_UPLOAD_MANAGEMENT.GsheetMenuItem = GsheetMenuItem;
PLUGIN_UPLOAD_MANAGEMENT.GsheetsSyncStatus = GsheetsSyncStatus;
PLUGIN_UPLOAD_MANAGEMENT.GsheetConnectButton = GsheetConnectButton;
}

View File

@@ -81,6 +81,16 @@ export type ErrorDiagnosticModalSubmittedEvent = ValidateEvent<{
event_detail: "download-diagnostics" | "submit-report";
}>;
export type GsheetsConnectionClickedEvent = ValidateEvent<{
event: "sheets_connection_clicked";
triggered_from: "db-page" | "left-nav";
}>;
export type GsheetsImportClickedEvent = ValidateEvent<{
event: "sheets_import_by_url_clicked";
triggered_from: "sheets-url-popup";
}>;
export type SimpleEvent =
| CSVUploadClickedEvent
| DatabaseAddClickedEvent
@@ -91,4 +101,6 @@ export type SimpleEvent =
| OnboardingChecklistItemCTAClickedEvent
| MoveToTrashEvent
| ErrorDiagnosticModalOpenedEvent
| ErrorDiagnosticModalSubmittedEvent;
| ErrorDiagnosticModalSubmittedEvent
| GsheetsConnectionClickedEvent
| GsheetsImportClickedEvent;

View File

@@ -208,6 +208,7 @@ export const createMockSettings = (
"enable-xrays": false,
engines: createMockEngines(),
"example-dashboard-id": 1,
gsheets: { status: "not-connected", folder_url: null },
"has-user-setup": true,
"hide-embed-branding?": true,
"instance-creation": dayjs().toISOString(),
@@ -252,6 +253,7 @@ export const createMockSettings = (
"slack-bug-report-channel": null,
"snowplow-enabled": false,
"show-database-syncing-modal": false,
"show-google-sheets-integration": false,
"show-homepage-data": false,
"show-homepage-pin-message": false,
"show-homepage-xrays": false,

View File

@@ -362,6 +362,11 @@ interface PublicSettings {
engines: Record<string, Engine>;
"google-auth-client-id": string | null;
"google-auth-enabled": boolean;
gsheets: {
status: "not-connected" | "loading" | "complete" | "error";
folder_url: string | null;
error?: string;
};
"has-user-setup": boolean;
"help-link": HelpLinkSetting;
"help-link-custom-destination": string;
@@ -384,6 +389,7 @@ interface PublicSettings {
"setup-token": string | null;
"show-metabase-links": boolean;
"show-metabot": boolean;
"show-google-sheets-integration": boolean;
"site-locale": string;
"site-url": string;
"snowplow-enabled": boolean;

View File

@@ -25,6 +25,7 @@ export * from "./revision";
export * from "./search";
export * from "./segment";
export * from "./session";
export * from "./settings";
export * from "./snippet";
export * from "./subscription";
export * from "./table";

View File

@@ -0,0 +1,16 @@
import type { SettingKey, SettingValue } from "metabase-types/api";
import { Api } from "./api";
export const settingApi = Api.injectEndpoints({
endpoints: builder => ({
getSetting: builder.query<SettingValue, SettingKey>({
query: settingName => ({
method: "GET",
url: `/api/setting/${settingName}`,
}),
}),
}),
});
export const { useGetSettingQuery } = settingApi;

View File

@@ -2,6 +2,7 @@ import { Link } from "react-router";
import { t } from "ttag";
import { color } from "metabase/lib/colors";
import { PLUGIN_UPLOAD_MANAGEMENT } from "metabase/plugins";
import { Flex, Group, Icon, Text, Title } from "metabase/ui";
import {
@@ -36,12 +37,7 @@ export const BrowseDataHeader = () => {
};
const LearnAboutDataLink = () => (
<Flex
p=".75rem"
justify="flex-end"
align="center"
style={{ flexBasis: "40.0%", marginInlineStart: "auto" }}
>
<Flex p=".75rem" justify="flex-end" align="center" gap="md">
<Link to="reference">
<BrowseHeaderIconContainer>
<LearnAboutDataIcon size={14} name="reference" />
@@ -50,5 +46,6 @@ const LearnAboutDataLink = () => (
</Text>
</BrowseHeaderIconContainer>
</Link>
<PLUGIN_UPLOAD_MANAGEMENT.GsheetConnectButton />
</Flex>
);

View File

@@ -43,7 +43,7 @@ import {
} from "../MainNavbar.styled";
import { SidebarCollectionLink, SidebarLink } from "../SidebarItems";
import { AddDatabase } from "../SidebarItems/AddDatabase";
import { DwhUploadCSV } from "../SidebarItems/DwhUploadCSV/DwhUploadCSV";
import { DwhUploadMenu } from "../SidebarItems/DwhUpload";
import { trackOnboardingChecklistOpened } from "../analytics";
import type { SelectedItem } from "../types";
@@ -172,7 +172,7 @@ export function MainNavbarView({
* - "upload" permissions for the attached DWH
*/
const canUpload = canCurateRootCollection && canUploadToDatabase;
const showUploadCSVButton = hasAttachedDWHFeature && canUpload;
const showUploadMenu = hasAttachedDWHFeature && canUpload;
const isAdditionalDatabaseAdded = getHasOwnDatabase(databases);
const showAddDatabaseButton = isAdmin && !isAdditionalDatabaseAdded;
@@ -210,7 +210,7 @@ export function MainNavbarView({
{t`How to use Metabase`}
</PaddedSidebarLinkDismissible>
)}
{showUploadCSVButton && <DwhUploadCSV />}
{showUploadMenu && <DwhUploadMenu />}
</SidebarSection>
{bookmarks.length > 0 && (

View File

@@ -1,5 +1,5 @@
import { type ChangeEvent, useCallback, useRef, useState } from "react";
import { c } from "ttag";
import { c, t } from "ttag";
import {
type CollectionOrTableIdProps,
@@ -9,20 +9,26 @@ import type { OnFileUpload } from "metabase/collections/types";
import { UploadInput } from "metabase/components/upload";
import { useToggle } from "metabase/hooks/use-toggle";
import { useDispatch } from "metabase/lib/redux";
import { PLUGIN_UPLOAD_MANAGEMENT } from "metabase/plugins";
import {
type UploadFileProps,
uploadFile as uploadFileAction,
} from "metabase/redux/uploads";
import { Box, Button, Icon } from "metabase/ui";
import { Box, Button, Flex, Icon, Menu } from "metabase/ui";
import { trackDWHUploadCSVClicked } from "./analytics";
export const DwhUploadCSV = () => {
export const DwhUploadMenu = () => {
const [
isModelUploadModalOpen,
{ turnOn: openModelUploadModal, turnOff: closeModelUploadModal },
] = useToggle(false);
const [
isGsheetModalOpen,
{ turnOn: openGsheetModal, turnOff: closeGsheetModal },
] = useToggle(false);
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const dispatch = useDispatch();
@@ -67,17 +73,36 @@ export const DwhUploadCSV = () => {
const triggerUploadInput = () => uploadInputRef.current?.click();
return (
<Box data-testid="dwh-upload-csv" mt="md" px="md">
<Button
color="brand"
fullWidth={true}
leftSection={<Icon name="upload" />}
onClick={triggerUploadInput}
radius="xl"
variant="outline"
>
{c("Text for a button that lets you upload a CSV file").t`Upload CSV`}
</Button>
<Box data-testid="dwh-upload" mt="md" px="md">
<Menu position="right">
<Menu.Target>
<Button
fullWidth
variant="outline"
styles={{ label: { width: "100%" } }}
>
<Flex justify="space-between" w="100%">
<span />
<Flex>
<Icon name="add_data" mr="sm" />
<Box>{t`Add Data`}</Box>
</Flex>
{/* need this on the far-right */}
<Icon name="chevrondown" ml="sm" />
</Flex>
</Button>
</Menu.Target>
<Menu.Dropdown miw="19rem">
<Menu.Item
fw="bold"
leftSection={<Icon name="upload" />}
onClick={triggerUploadInput}
>
{c("button label for uploading a CSV data file").t`Upload CSV`}
</Menu.Item>
<PLUGIN_UPLOAD_MANAGEMENT.GsheetMenuItem onClick={openGsheetModal} />
</Menu.Dropdown>
</Menu>
<UploadInput
id="dwh-upload-csv-input"
ref={uploadInputRef}
@@ -89,6 +114,11 @@ export const DwhUploadCSV = () => {
onClose={closeModelUploadModal}
onUpload={handleFileUpload}
/>
<PLUGIN_UPLOAD_MANAGEMENT.GsheetConnectionModal
isModalOpen={isGsheetModalOpen}
onClose={closeGsheetModal}
reconnect={true}
/>
</Box>
);
};

View File

@@ -0,0 +1 @@
export * from "./DwhUpload";

View File

@@ -1,9 +1,11 @@
import { setupGsheetsServiceAccountEndpoint } from "__support__/server-mocks";
import { screen } from "__support__/ui";
import { createMockUser } from "metabase-types/api/mocks";
import { type SetupOpts, setup } from "./setup";
const setupEnterprise = (opts?: SetupOpts) => {
setupGsheetsServiceAccountEndpoint();
return setup({ ...opts, hasEnterprisePlugins: true });
};

View File

@@ -1,9 +1,13 @@
import userEvent from "@testing-library/user-event";
import { setupGsheetsServiceAccountEndpoint } from "__support__/server-mocks";
import { screen, within } from "__support__/ui";
import { createMockUser } from "metabase-types/api/mocks";
import { type SetupOpts, setup } from "./setup";
function setupPremium(opts: SetupOpts) {
setupGsheetsServiceAccountEndpoint();
return setup({
hasEnterprisePlugins: true,
hasDWHAttached: true,
@@ -12,12 +16,32 @@ function setupPremium(opts: SetupOpts) {
}
describe("nav > containers > MainNavbar (EE with token)", () => {
it("should render 'upload CSV' button to admins", async () => {
it("should render 'add data' button to admins", async () => {
await setupPremium({
user: createMockUser({ is_superuser: true }),
});
const nav = screen.getByTestId("main-navbar-root");
expect(within(nav).getByText("Upload CSV")).toBeInTheDocument();
expect(within(nav).getByText("Add Data")).toBeInTheDocument();
});
it("should render 'upload CSV' menu item to admins", async () => {
await setupPremium({
user: createMockUser({ is_superuser: true }),
});
const nav = screen.getByTestId("main-navbar-root");
await userEvent.click(within(nav).getByText("Add Data"));
expect(await screen.findByText("Upload CSV")).toBeInTheDocument();
});
it("should render gsheets upload menu item to admins", async () => {
await setupPremium({
user: createMockUser({ is_superuser: true }),
});
const nav = screen.getByTestId("main-navbar-root");
await userEvent.click(within(nav).getByText("Add Data"));
expect(
await screen.findByText("Connect Google Sheets"),
).toBeInTheDocument();
});
it("should render 'upload CSV' button to regular users who have sufficient permissions", async () => {
@@ -27,7 +51,8 @@ describe("nav > containers > MainNavbar (EE with token)", () => {
user: createMockUser({ is_superuser: false }),
});
const nav = screen.getByTestId("main-navbar-root");
expect(within(nav).getByText("Upload CSV")).toBeInTheDocument();
await userEvent.click(within(nav).getByText("Add Data"));
expect(await screen.findByText("Upload CSV")).toBeInTheDocument();
});
it("should not render 'upload CSV' button to regular users who lack root collection permissions", async () => {

View File

@@ -11,6 +11,7 @@ import {
setupDatabasesEndpoints,
setupSearchEndpoints,
} from "__support__/server-mocks";
import { mockSettings } from "__support__/settings";
import { createMockEntitiesState } from "__support__/store";
import {
renderWithProviders,
@@ -30,7 +31,6 @@ import type { DashboardState } from "metabase-types/store";
import {
createMockDashboardState,
createMockQueryBuilderState,
createMockSettingsState,
createMockState,
} from "metabase-types/store/mocks";
@@ -158,7 +158,7 @@ export async function setup({
}),
qb: createMockQueryBuilderState({ card: openQuestionCard }),
entities: createMockEntitiesState({ dashboards: dashboardsForEntities }),
settings: createMockSettingsState({
settings: mockSettings({
"uploads-settings": {
db_id: hasDWHAttached || isUploadEnabled ? SAMPLE_DATABASE.id : null,
schema_name: null,
@@ -167,7 +167,10 @@ export async function setup({
"instance-creation": instanceCreationDate,
"token-features": createMockTokenFeatures({
attached_dwh: hasDWHAttached,
hosting: true,
upload_management: true,
}),
"show-google-sheets-integration": true,
}),
});

View File

@@ -569,8 +569,19 @@ export const PLUGIN_AUDIT = {
InsightsLink: PluginPlaceholder as ComponentType<InsightsLinkProps>,
};
type GsheetConnectionModalProps = {
isModalOpen: boolean;
onClose: () => void;
reconnect: boolean;
};
export const PLUGIN_UPLOAD_MANAGEMENT = {
UploadManagementTable: PluginPlaceholder,
GsheetsSyncStatus: PluginPlaceholder,
GsheetConnectionModal:
PluginPlaceholder as ComponentType<GsheetConnectionModalProps>,
GsheetMenuItem: PluginPlaceholder as ComponentType<{ onClick: () => void }>,
GsheetConnectButton: PluginPlaceholder,
};
export const PLUGIN_IS_EE_BUILD = {

View File

@@ -234,7 +234,7 @@ describe("FileUploadStatus", () => {
});
expect(
await screen.findByRole("link", { name: "Start exploring" }),
await screen.findByRole("link", { name: /Start exploring/ }),
).toHaveAttribute("href", "/model/3");
});
@@ -268,7 +268,7 @@ describe("FileUploadStatus", () => {
});
expect(
await screen.findByRole("link", { name: "Start exploring" }),
await screen.findByRole("link", { name: /Start exploring/ }),
).toHaveAttribute("href", "/model/3");
});
@@ -311,7 +311,7 @@ describe("FileUploadStatus", () => {
});
expect(
await screen.findByRole("link", { name: "Start exploring" }),
await screen.findByRole("link", { name: /Start exploring/ }),
).toHaveAttribute("href", "/model/3");
});
@@ -358,7 +358,7 @@ describe("FileUploadStatus", () => {
});
expect(
await screen.findByRole("link", { name: "Start exploring" }),
await screen.findByRole("link", { name: /Start exploring/ }),
).toHaveAttribute("href", "/model/3");
await screen.findByText("Data added to Fancy Table");
});
@@ -405,7 +405,7 @@ describe("FileUploadStatus", () => {
});
expect(
await screen.findByRole("link", { name: "Start exploring" }),
await screen.findByRole("link", { name: /Start exploring/ }),
).toHaveAttribute("href", "/model/3");
await screen.findByText("Data replaced in Fancy Table");
});
@@ -492,7 +492,7 @@ describe("FileUploadStatus", () => {
});
expect(
await screen.findByRole("link", { name: "Start exploring" }),
await screen.findByRole("link", { name: /Start exploring/ }),
).toHaveAttribute("href", "/model/3");
});
});

View File

@@ -56,6 +56,7 @@ const FileUploadLarge = ({
id: upload.id,
title: getName(upload),
icon: "model",
href: upload.modelId ? `/model/${upload.modelId}` : undefined,
description: Description({ upload }),
isInProgress: isUploadInProgress(upload),
isCompleted: isUploadCompleted(upload),
@@ -122,7 +123,7 @@ const getLoadingMessage = (time: number) => {
const Description = ({ upload }: { upload: FileUpload }) => {
if (upload.status === "complete" && upload.modelId) {
return <Link to={`/model/${upload.modelId}`}>Start exploring</Link>;
return t`Start exploring`;
}
if (upload.status === "error") {

View File

@@ -1,6 +1,7 @@
import { t } from "ttag";
import { Ellipsified } from "metabase/core/components/Ellipsified";
import Link from "metabase/core/components/Link";
import type { IconName } from "metabase/ui";
import { Icon } from "metabase/ui";
@@ -28,6 +29,7 @@ type Status = {
type StatusItem = {
id?: number;
href?: string;
title: string | JSX.Element;
icon: string;
description?: string | JSX.Element;
@@ -68,7 +70,11 @@ const StatusLarge = ({
</StatusHeader>
<StatusBody>
{status.items.map(item => (
<StatusCard item={item} isActive={isActive} key={item.id} />
<StatusCard
item={item}
isActive={isActive}
key={item.id ?? String(item.title)}
/>
))}
</StatusBody>
</StatusRoot>
@@ -89,33 +95,38 @@ const StatusCard = ({
const isVisible = useStatusVisibility(isActive || isInProgress);
const LinkWrapper = ({ children }: { children: JSX.Element }) =>
item?.href ? <Link to={item.href}>{children}</Link> : children;
if (!isVisible) {
return null;
}
return (
<StatusCardRoot key={id} hasBody={!!description}>
<StatusCardIcon>
<Icon name={icon as unknown as IconName} />
</StatusCardIcon>
<StatusCardBody>
<StatusCardTitle>
<Ellipsified>{title}</Ellipsified>
</StatusCardTitle>
<StatusCardDescription>{description}</StatusCardDescription>
</StatusCardBody>
{isInProgress && <StatusCardSpinner size={24} borderWidth={3} />}
{isCompleted && (
<StatusCardIconContainer>
<Icon name="check" size={12} />
</StatusCardIconContainer>
)}
{isAborted && (
<StatusCardIconContainer isError={true}>
<Icon name="warning" size={12} />
</StatusCardIconContainer>
)}
</StatusCardRoot>
<LinkWrapper key={id}>
<StatusCardRoot hasBody={!!description}>
<StatusCardIcon>
<Icon name={icon as unknown as IconName} />
</StatusCardIcon>
<StatusCardBody>
<StatusCardTitle>
<Ellipsified>{title}</Ellipsified>
</StatusCardTitle>
<StatusCardDescription>{description}</StatusCardDescription>
</StatusCardBody>
{isInProgress && <StatusCardSpinner size={24} borderWidth={3} />}
{isCompleted && (
<StatusCardIconContainer>
<Icon name="check" size={12} />
</StatusCardIconContainer>
)}
{isAborted && (
<StatusCardIconContainer isError={true}>
<Icon name="warning" size={12} />
</StatusCardIconContainer>
)}
</StatusCardRoot>
</LinkWrapper>
);
};

View File

@@ -2,6 +2,7 @@ import { useBeforeUnload } from "react-use";
import { t } from "ttag";
import { useSelector } from "metabase/lib/redux";
import { PLUGIN_UPLOAD_MANAGEMENT } from "metabase/plugins";
import { hasActiveUploads } from "metabase/redux/uploads";
import { getUserIsAdmin } from "metabase/selectors/user";
import { useCheckActiveDownloadsBeforeUnload } from "metabase/status/hooks/use-check-active-downloads-before-unload";
@@ -29,6 +30,7 @@ const StatusListing = () => {
{isAdmin && <DatabaseStatus />}
<FileUploadStatus />
<DownloadsStatus />
<PLUGIN_UPLOAD_MANAGEMENT.GsheetsSyncStatus />
</StatusListingRoot>
);
};

View File

@@ -0,0 +1,15 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_12770_5520)">
<path d="M2.05842 12.4663L2.67583 13.5327C2.80412 13.7572 2.98855 13.9336 3.20504 14.0619L5.41008 10.2452H1C1 10.4937 1.06415 10.7423 1.19244 10.9668L2.05842 12.4663Z" fill="#0066DA"/>
<path d="M8 5.7549L5.79496 1.93817C5.57846 2.06646 5.39404 2.24287 5.26575 2.46738L1.19244 9.52351C1.06651 9.74318 1.00017 9.99195 1 10.2452H5.41008L8 5.7549Z" fill="#00AC47"/>
<path d="M12.7947 14.0619C13.0112 13.9336 13.1956 13.7572 13.3239 13.5327L13.5805 13.0917L14.8073 10.9668C14.9356 10.7423 14.9998 10.4937 14.9998 10.2452H10.5894L11.5278 12.0894L12.7947 14.0619Z" fill="#EA4335"/>
<path d="M7.99996 5.75489L10.205 1.93817C9.98851 1.80987 9.73994 1.74573 9.48335 1.74573H6.51657C6.25999 1.74573 6.01142 1.81789 5.79492 1.93817L7.99996 5.75489Z" fill="#00832D"/>
<path d="M10.59 10.2452H5.41012L3.20508 14.0619C3.42157 14.1902 3.67014 14.2543 3.92673 14.2543H12.0733C12.3299 14.2543 12.5785 14.1822 12.795 14.0619L10.59 10.2452Z" fill="#2684FC"/>
<path d="M12.7709 5.99544L10.7342 2.46738C10.606 2.24287 10.4215 2.06646 10.205 1.93817L8 5.7549L10.5899 10.2452H14.992C14.992 9.99659 14.9278 9.74802 14.7995 9.52351L12.7709 5.99544Z" fill="#FFBA00"/>
</g>
<defs>
<clipPath id="clip0_12770_5520">
<rect width="14" height="12.5086" fill="white" transform="translate(1 1.74573)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.8838 5.53033L12.8839 5.53034C13.1183 5.76477 13.25 6.08271 13.25 6.41421V13C13.25 13.6904 12.6904 14.25 12 14.25H4C3.30964 14.25 2.75 13.6904 2.75 13V3C2.75 2.30964 3.30964 1.75 4 1.75H8.58579C8.91731 1.75 9.23525 1.88169 9.46966 2.11611L12.8838 5.53033ZM9 2.35354V2.25H8.5H4C3.58579 2.25 3.25 2.58579 3.25 3V13C3.25 13.4143 3.58583 13.75 4 13.75H12C12.4142 13.75 12.75 13.4142 12.75 13V6.5V6H12.6464L11.7929 5.14645L9.85356 3.20711L9 2.35354Z" fill="#4C5773" stroke="#4C5773"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 1.25C3.0335 1.25 2.25 2.0335 2.25 3V13C2.25 13.9665 3.0335 14.75 4 14.75H12C12.9665 14.75 13.75 13.9665 13.75 13V6.41421C13.75 5.95008 13.5656 5.50497 13.2374 5.17678L9.82322 1.76256C9.49503 1.43437 9.04992 1.25 8.58579 1.25H4Z" fill="#26A668"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.7003 6.00012H9.5C9.22386 6.00012 9 5.77626 9 5.50012V1.2998C9.30909 1.37511 9.59454 1.53397 9.82322 1.76265L13.2374 5.17686C13.4661 5.40555 13.625 5.69101 13.7003 6.00012Z" fill="#92D3B4"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.5 8.5H10.5V9.5H5.5V8.5ZM9.5 10.5H5.5V11.5H9.5V10.5Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -167,6 +167,10 @@ import globe_component from "./globe.svg?component";
import globe_source from "./globe.svg?source";
import google_component from "./google.svg?component";
import google_source from "./google.svg?source";
import google_drive_component from "./google_drive.svg?component";
import google_drive_source from "./google_drive.svg?source";
import google_sheet_component from "./google_sheet.svg?component";
import google_sheet_source from "./google_sheet.svg?source";
import grabber_component from "./grabber.svg?component";
import grabber_source from "./grabber.svg?source";
import grid_component from "./grid.svg?component";
@@ -753,6 +757,14 @@ export const Icons = {
component: google_component,
source: google_source,
},
google_drive: {
component: google_drive_component,
source: google_drive_source,
},
google_sheet: {
component: google_sheet_component,
source: google_sheet_source,
},
history: {
component: history_component,
source: history_source,

View File

@@ -0,0 +1,36 @@
import fetchMock from "fetch-mock";
import type { Settings } from "metabase-types/api";
type Props =
| {
status?: never;
errorCode: number;
}
| {
status: Settings["gsheets"]["status"];
errorCode?: never;
};
export function setupGsheetsGetFolderEndpoint({ status, errorCode }: Props) {
if (status) {
fetchMock.get("path:/api/ee/gsheets/folder", () => {
return {
status,
db_id: 1,
};
});
}
if (errorCode) {
fetchMock.get("path:/api/ee/gsheets/folder", errorCode);
}
}
export function setupGsheetsServiceAccountEndpoint(
email = "service-account123@testing.metabase.com",
) {
fetchMock.get("path:/api/ee/gsheets/service-account", () => {
return { email };
});
}

View File

@@ -15,6 +15,7 @@ export * from "./dataset";
export * from "./email";
export * from "./field";
export * from "./group";
export * from "./gsheets";
export * from "./impersonation";
export * from "./model-indexes";
export * from "./native-query-snippet";

View File

@@ -271,7 +271,16 @@
{:description "Number of errors when sending channel notifications."
:labels [:payload-type :channel-type]})
(prometheus/gauge :metabase-notification/concurrent-tasks
{:description "Number of concurrent notification sends."})])
{:description "Number of concurrent notification sends."})
(prometheus/counter :metabase-gsheets/connection-creation-began
{:description "How many times the instance has initiated a Google Sheets connection creation."})
(prometheus/counter :metabase-gsheets/connection-creation-ok
{:description "How many times the instance has created a Google Sheets connection."})
(prometheus/counter :metabase-gsheets/connection-creation-error
{:description "How many failures there were when creating a Google Sheets connection."
:labels [:reason]})
(prometheus/counter :metabase-gsheets/connection-deleted
{:description "How many times the instance has deleted their Google Sheets connection."})])
(defmulti known-labels
"Implement this for a given metric to initialize it for the given set of label values."
@@ -413,5 +422,15 @@
(prometheus/set (:registry system) metric (qualified-vals labels) amount)))
(comment
;; want to see what's in the registry?
(require 'iapetos.export)
(spit "metrics" (iapetos.export/text-format (:registry system))))
(spit "metrics" (iapetos.export/text-format (:registry system)))
;; need to restart the server to see the metrics? use:
(shutdown!)
;; get the value of a metric:
(prometheus/value (:registry system) :metabase-gsheets/connection-creation-began)
;; w/ a label:
(prometheus/value (:registry system) :metabase-gsheets/connection-creation-error [[:reason "timeout"]]))

View File

@@ -58,6 +58,7 @@
:snowplow/embed_share "1-0-0"
:snowplow/llm_usage "1-0-0"
:snowplow/serialization "1-0-1"
:snowplow/simple_event "1-0-0"
:snowplow/cleanup "1-0-0"})
(def ^:private SnowplowSchema

11
src/metabase/api/auth.clj Normal file
View File

@@ -0,0 +1,11 @@
(ns metabase.api.auth
(:require
[metabase.models.setting :refer [defsetting]]))
(defsetting api-key
"When set, this API key is required for all API requests."
:encryption :when-encryption-key-set
:visibility :internal
:doc "Middleware that enforces validation of the client via the request header X-Metabase-Apikey.
If the header is available, then it’s validated against MB_API_KEY.
When it matches, the request continues; otherwise it’s blocked with a 403 Forbidden response.")

View File

@@ -1149,7 +1149,8 @@
[{:keys [id]} :- [:map
[:id ms/PositiveInt]]]
(let [db (api/check-404 (t2/select-one :model/Database id))]
(api/check-403 (mi/can-write? db))
(or (api/check-403 (mi/can-write? db))
(:is_attached_dwh db))
(->> db
(driver/syncable-schemas (:engine db))
(vec)

View File

@@ -8,4 +8,6 @@
(p/import-vars
[metabase.cloud-migration.settings
read-only-mode
read-only-mode!])
read-only-mode!
store-api-url])

View File

@@ -68,13 +68,13 @@
(u.secret/secret
(str "mb_" (crypto-random/base64 bytes-key-length))))
(def ^:private string-key-length (count (u.secret/expose (generate-key))))
(def ^:private string-key-length (delay (count (u.secret/expose (generate-key)))))
(defn mask
"Given an API key, returns a string of the same length with all but the prefix masked with `*`s"
[key]
(->> (concat (prefix key) (repeat "*"))
(take string-key-length)
(take @string-key-length)
(apply str)))
(defn- add-key

View File

@@ -325,6 +325,7 @@
clojure.lang.Keyword
(resolve-setting [k]
(or (@registered-settings k)
(@registered-settings (u/->kebab-case-en k))
(throw (ex-info (tru "Unknown setting: {0}" k)
{::unknown-setting-error true
:registered-settings

View File

@@ -110,7 +110,7 @@
[:company {:optional true} [:string {:min 1}]]])
(def ^:private ^:const token-status-cache-ttl
"Amount of time to cache the status of a valid enterprise token before forcing a re-check."
"Amount of time in ms to cache the status of a valid enterprise token before forcing a re-check."
(u/hours->ms 12))
(def ^{:arglists '([token base-url site-uuid])} ^:private fetch-token-and-parse-body*

View File

@@ -3,10 +3,15 @@
is not used as part of the normal `app`; it is instead added selectively to appropriate routes."
(:require
[clojure.string :as str]
[metabase.models.setting :refer [defsetting]]
[metabase.api.auth :as api.auth]
[metabase.models.setting :as setting]
[metabase.request.core :as request]
[metabase.util.i18n :refer [deferred-trs]]))
(comment
;; needed to load the api-key setting
api.auth/keep-me)
(def ^:private ^:const ^String static-metabase-api-key-header "x-metabase-apikey")
(defn enforce-authentication
@@ -33,18 +38,11 @@
(handler (wrap-static-api-key* request) respond raise))
(meta handler)))
(defsetting api-key
"When set, this API key is required for all API requests."
:encryption :when-encryption-key-set
:visibility :internal
:doc "Middleware that enforces validation of the client via the request header X-Metabase-Apikey.
If the header is available, then it’s validated against MB_API_KEY.
When it matches, the request continues; otherwise it’s blocked with a 403 Forbidden response.")
(defn static-api-key
"We don't want to change the name of the setting from `MB_API_KEY`, but we want to differentiate this static key from
the API keys that can be generated by admins."
[] (api-key))
[]
(setting/get :api-key))
(def mb-api-key-doc-url
"Url for documentation on how to set MB_API_KEY."

View File

@@ -15,7 +15,8 @@
;; for databases that support forcing query to include a filter (e.g: partitioned table on bigquery)
[:database_require_filter {:optional true} [:maybe :boolean]]
;; `:description` in this case should be a column/remark on the Table, if there is one.
[:description {:optional true} [:maybe :string]]])
[:description {:optional true} [:maybe :string]]
[:visibility_type {:optional true} [:maybe :string]]])
(def DatabaseMetadataTable
"Schema for the expected output of `describe-database` for a Table."
@@ -49,7 +50,8 @@
[:database-is-auto-increment {:optional true} :boolean]
;; nullable for databases that don't support field partition
[:database-partitioned {:optional true} [:maybe :boolean]]
[:database-required {:optional true} :boolean]])
[:database-required {:optional true} :boolean]
[:visibility-type {:optional true} [:maybe :keyword]]])
(def TableMetadataField
"Schema for a given Field as provided in [[metabase.driver/describe-table]]."

View File

@@ -0,0 +1,34 @@
(ns metabase.sync.sync-metadata.crufty
"This namespace contains functions for determining whether or not a table or column name is 'crufty' during syncs.
In practice, this means that the table or column name is automatically hidden during a syncs.
We do not _unhide_ tables or columns that are hidden during syncs, so that in the case where a table or column name
happens to match our idea of crufty, but admins want to see it, they can manually unhide tables or columns, and
continue to see them."
(:require
[metabase.util :as u]))
(defn- matches-any-patterns? [name patterns]
(some #(re-find % name) patterns))
(defn- ->lower-cased-regex
"Converts a pattern-string into a lower-cased regex."
[pattern]
(-> pattern u/lower-case-en re-pattern))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PUBLIC API
(defn name?
"Returns true if the table name re-find matches any of the (regex) patterns or pattern-strings (after getting passed
through re-pattern).
This is what does the work of deciding whether or not a table or column name is automatically hidden during syncs.
These are set from database.setting.auto-cruft-tables and database.setting.auto-cruft-columns, and are both vectors
of strings that are converted into regexes.
If you want to match a table or column name exactly, use `^my-table$` or `^my-column$`."
[nname regexes+pattern-strings]
(matches-any-patterns? (u/lower-case-en nname)
(map ->lower-cased-regex regexes+pattern-strings)))

View File

@@ -40,6 +40,7 @@
for logging purposes by higher-level sync logic."
(:require
[metabase.driver.util :as driver.u]
[metabase.models.setting :refer [defsetting]]
[metabase.models.table :as table]
[metabase.sync.fetch-metadata :as fetch-metadata]
[metabase.sync.interface :as i]
@@ -53,6 +54,16 @@
[toucan2.core :as t2]
[toucan2.util :as t2.util]))
(defsetting auto-cruft-columns
"A list of pattern strings that get converted into additional regexes that match Fields that should automatically be
marked as visibility-type = `:hidden`. Not to be set directly, this setting lives in the metabase_database.settings json blob."
:type :json
:database-local :only
:visibility :internal
:default []
:export? true
:encryption :no)
;;; +----------------------------------------------------------------------------------------------------------------+
;;; | PUTTING IT ALL TOGETHER |
;;; +----------------------------------------------------------------------------------------------------------------+
@@ -60,13 +71,14 @@
(mu/defn- sync-and-update! :- ms/IntGreaterThanOrEqualToZero
"Sync Field instances (i.e., rows in the Field table in the Metabase application DB) for a Table, and update metadata
properties (e.g. base type and comment/remark) as needed. Returns number of Fields synced."
[table :- i/TableInstance
[database :- i/DatabaseInstance
table :- i/TableInstance
db-metadata :- [:set i/TableMetadataField]]
(+ (sync-instances/sync-instances! table db-metadata (fields.our-metadata/our-metadata table))
;; Now that tables are synced and fields created as needed make sure field properties are in sync.
;; Re-fetch our metadata because there might be somethings that have changed after calling
;; Re-fetch our metadata because there might be some things that have changed after calling
;; `sync-instances`
(sync-metadata/update-metadata! table db-metadata (fields.our-metadata/our-metadata table))))
(sync-metadata/update-metadata! database table db-metadata (fields.our-metadata/our-metadata table))))
(mu/defn sync-fields! :- [:map
[:updated-fields ms/IntGreaterThanOrEqualToZero]
@@ -97,7 +109,7 @@
(set table-metadata)
database
table)]
(sync-and-update! table all-metadata))
(sync-and-update! database table all-metadata))
(catch Exception e
(log/error e)
0))
@@ -123,4 +135,4 @@
;; Also this should be a driver method, not a sql-jdbc.sync method
db-metadata (fetch-metadata/include-nested-fields-for-table db-metadata database table)]
{:total-fields (count db-metadata)
:updated-fields (sync-and-update! table db-metadata)}))))
:updated-fields (sync-and-update! database table db-metadata)}))))

View File

@@ -34,7 +34,8 @@
:position (:position field)
:database-position (:database_position field)
:database-partitioned (:database_partitioned field)
:database-required (:database_required field)})
:database-required (:database_required field)
:visibility-type (:visibility_type field)})
;; make a map of parent-id -> set of child Fields
(group-by :parent-id)
;; remove the parent ID because the Metadata from `describe-table` won't have it. Save the results as a set

View File

@@ -5,6 +5,7 @@
(:require
[clojure.string :as str]
[metabase.sync.interface :as i]
[metabase.sync.sync-metadata.crufty :as crufty]
[metabase.sync.sync-metadata.fields.common :as common]
[metabase.sync.util :as sync-util]
[metabase.util :as u]
@@ -13,10 +14,20 @@
[metabase.util.malli.schema :as ms]
[toucan2.core :as t2]))
(defn- compute-new-visibility-type [db field-metadata]
(if (crufty/name? (:name field-metadata)
(some-> db :settings :auto-cruft-columns))
:details-only
;; n.b. if it was auto-crufted in the past, removing it from auto-cruft will NOT make it visible because old
;; visibility-type will be :details-only. This only changes things to be hidden. If you want to make it visible
;; again, you need to change the visibility-type to :normal via the fields :put api.
(:visibility-type field-metadata)))
(mu/defn- update-field-metadata-if-needed! :- [:enum 0 1]
"Update the metadata for a Metabase Field as needed if any of the info coming back from the DB has changed. Syncs
base type, database type, semantic type, and comments/remarks; returns `1` if the Field was updated; `0` otherwise."
[table :- i/TableInstance
[database :- i/DatabaseInstance
table :- i/TableInstance
field-metadata :- i/TableMetadataField
metabase-field :- common/TableMetadataFieldWithID]
(let [{old-database-type :database-type
@@ -28,7 +39,8 @@
old-database-name :name
old-database-is-auto-increment :database-is-auto-increment
old-db-partitioned :database-partitioned
old-db-required :database-required} metabase-field
old-db-required :database-required
old-visibility-type :visibility-type} metabase-field
{new-database-type :database-type
new-base-type :base-type
new-field-comment :field-comment
@@ -37,6 +49,7 @@
new-database-is-auto-increment :database-is-auto-increment
new-db-partitioned :database-partitioned
new-db-required :database-required} field-metadata
new-visibility-type (compute-new-visibility-type database field-metadata)
new-database-is-auto-increment (boolean new-database-is-auto-increment)
new-db-required (boolean new-db-required)
new-database-type (or new-database-type "NULL")
@@ -67,6 +80,7 @@
new-db-auto-incremented? (not= old-database-is-auto-increment new-database-is-auto-increment)
new-db-partitioned? (not= new-db-partitioned old-db-partitioned)
new-db-required? (not= old-db-required new-db-required)
new-visibility-type? (not= old-visibility-type new-visibility-type)
;; calculate combined updates
updates
@@ -136,7 +150,9 @@
(common/field-metadata-name-for-logging table metabase-field)
old-db-required
new-db-required)
{:database_required new-db-required}))]
{:database_required new-db-required})
(when new-visibility-type?
{:visibility_type new-visibility-type}))]
;; if any updates need to be done, do them and return 1 (because 1 Field was updated), otherwise return 0
(if (and (seq updates)
(pos? (t2/update! :model/Field (u/the-id metabase-field) updates)))
@@ -147,24 +163,26 @@
(mu/defn- update-nested-fields-metadata! :- ms/IntGreaterThanOrEqualToZero
"Recursively call `update-metadata!` for all the nested Fields in a `metabase-field`."
[table :- i/TableInstance
[database :- i/DatabaseInstance
table :- i/TableInstance
field-metadata :- i/TableMetadataField
metabase-field :- common/TableMetadataFieldWithID]
(let [nested-fields-metadata (:nested-fields field-metadata)
metabase-nested-fields (:nested-fields metabase-field)]
(if (seq metabase-nested-fields)
(update-metadata! table (set nested-fields-metadata) (set metabase-nested-fields))
(update-metadata! database table (set nested-fields-metadata) (set metabase-nested-fields))
0)))
(mu/defn update-metadata! :- ms/IntGreaterThanOrEqualToZero
"Make sure things like PK status and base-type are in sync with what has come back from the DB. Recursively updates
nested Fields. Returns total number of Fields updated."
[table :- i/TableInstance
[database :- i/DatabaseInstance
table :- i/TableInstance
db-metadata :- [:set i/TableMetadataField]
our-metadata :- [:set common/TableMetadataFieldWithID]]
(sync-util/sum-for [metabase-field our-metadata]
;; only update metadata for 'existing' Fields that are present in our Metadata (i.e., present in the application
;; database) and that are still considered active (i.e., present in DB metadata)
(when-let [field-metadata (common/matching-field-metadata metabase-field db-metadata)]
(+ (update-field-metadata-if-needed! table field-metadata metabase-field)
(update-nested-fields-metadata! table field-metadata metabase-field)))))
(+ (update-field-metadata-if-needed! database table field-metadata metabase-field)
(update-nested-fields-metadata! database table field-metadata metabase-field)))))

View File

@@ -7,8 +7,10 @@
[metabase.lib.schema.common :as lib.schema.common]
[metabase.models.humanization :as humanization]
[metabase.models.interface :as mi]
[metabase.models.setting :refer [defsetting]]
[metabase.sync.fetch-metadata :as fetch-metadata]
[metabase.sync.interface :as i]
[metabase.sync.sync-metadata.crufty :as crufty]
[metabase.sync.sync-metadata.metabase-metadata :as metabase-metadata]
[metabase.sync.util :as sync-util]
[metabase.util :as u]
@@ -75,10 +77,15 @@
;; MSSQL
#"^syncobj_0x.*"})
(mu/defn- is-crufty-table?
"Should we give newly created TABLE a `visibility_type` of `:cruft`?"
[table-name]
(some #(re-find % (u/lower-case-en table-name)) crufty-table-patterns))
(defsetting auto-cruft-tables
"A list of pattern strings that get converted into additional regexes that match Tables that should automatically be
marked as `:cruft`."
:type :json
:database-local :only
:visibility :internal
:export? true
:default []
:encryption :no)
;;; ---------------------------------------------------- Syncing -----------------------------------------------------
@@ -91,10 +98,18 @@
{:details
(assoc (:details database) :version (:version db-metadata))}))
(defn- cruft-dependent-columns [table-name]
;; if this is a crufty table, mark initial sync as complete since we'll skip the subsequent sync steps
(let [is-crufty? (is-crufty-table? table-name)]
{:initial_sync_status (if is-crufty? "complete" "incomplete")
(mu/defn- cruft-dependent-cols [{table-name :name :as table}
database
sync-stage :- [:enum ::reactivate ::create ::update]]
(let [is-crufty? (crufty/name? table-name (into crufty-table-patterns
(some-> database :settings :auto-cruft-tables)))]
{:initial_sync_status (cond
;; if we're updating a table, we don't overwrite the initial sync status, so that it remain
;; "complete" during the sync. See:
;; [[metabase.sync.util-test/initial-sync-status-table-only-test]]
(= sync-stage ::update) (:initial_sync_status table)
is-crufty? "complete"
:else "incomplete")
:visibility_type (when is-crufty? :cruft)}))
(defn create-table!
@@ -103,13 +118,14 @@
[database table]
(t2/insert-returning-instance!
:model/Table
(merge (cruft-dependent-columns (:name table))
(merge (cruft-dependent-cols table database ::create)
{:active true
:db_id (:id database)
:schema (:schema table)
:description (:description table)
:database_require_filter (:database_require_filter table)
:display_name (or (:display_name table) (humanization/name->human-readable-name (:name table)))
:display_name (or (:display_name table)
(humanization/name->human-readable-name (:name table)))
:name (:name table)})))
(defn create-or-reactivate-table!
@@ -120,8 +136,11 @@
:schema schema
:name table-name
:active false)]
;; if the table already exists but is marked *inactive*, mark it as *active*
(t2/update! :model/Table existing-id (assoc (cruft-dependent-columns (:name table)) :active true))
(do (t2/select-one :model/Table existing-id)
;; if the table already exists but is marked *inactive*, mark it as *active*
(t2/update! :model/Table existing-id (assoc
(cruft-dependent-cols table database ::reactivate)
:active true)))
;; otherwise create a new Table
(create-table! database table)))
@@ -130,12 +149,12 @@
(mu/defn- create-or-reactivate-tables!
"Create `new-tables` for database, or if they already exist, mark them as active."
[database :- i/DatabaseInstance
new-tables :- [:set i/DatabaseMetadataTable]]
(doseq [table new-tables]
new-table-metadatas :- [:set i/DatabaseMetadataTable]]
(doseq [table-metadata new-table-metadatas]
(log/info "Found new table:"
(sync-util/name-for-logging (mi/instance :model/Table table))))
(doseq [table new-tables]
(create-or-reactivate-table! database table)))
(sync-util/name-for-logging (mi/instance :model/Table table-metadata))))
(doseq [table-metadata new-table-metadatas]
(create-or-reactivate-table! database table-metadata)))
(mu/defn- retire-tables!
"Mark any `old-tables` belonging to `database` as inactive."
@@ -153,23 +172,30 @@
:active true}
{:active false})))
(def ^:private keys-to-update
[:description :database_require_filter :estimated_row_count :visibility_type :initial_sync_status])
(mu/defn- update-table-metadata-if-needed!
"Update the table metadata if it has changed."
[table-metadata :- i/DatabaseMetadataTable
metabase-table :- (ms/InstanceOf :model/Table)]
metabase-table :- (ms/InstanceOf :model/Table)
metabase-database :- (ms/InstanceOf :model/Database)]
(log/infof "Updating table metadata for %s" (sync-util/name-for-logging metabase-table))
(let [to-update-keys [:description :database_require_filter :estimated_row_count]
old-table (select-keys metabase-table to-update-keys)
new-table (select-keys (merge
(zipmap to-update-keys (repeat nil))
table-metadata)
to-update-keys)
[_ changes _] (data/diff old-table new-table)
changes (cond-> changes
;; we only update the description if the initial state is nil
;; because don't want to override the user edited description if it exists
(some? (:description old-table))
(dissoc changes :description))]
(let [old-table (select-keys metabase-table keys-to-update)
new-table (-> (zipmap keys-to-update (repeat nil))
(merge table-metadata
(cruft-dependent-cols metabase-table metabase-database
::update))
(select-keys keys-to-update))
[_ changes _] (data/diff old-table new-table)
changes (cond-> changes
;; we only update the description if the initial state is nil
;; because don't want to override the user edited description if it exists:
(some? (:description old-table))
(dissoc changes :description)
(= (:visibility_type new-table) (:visibility_type old-table))
(dissoc changes :visibility_type))]
(doseq [[k v] changes]
(log/infof "%s of %s changed from %s to %s"
k
@@ -181,11 +207,14 @@
(mu/defn- update-tables-metadata-if-needed!
[table-metadatas :- [:set i/DatabaseMetadataTable]
metabase-tables :- [:set (ms/InstanceOf :model/Table)]]
metabase-tables :- [:set (ms/InstanceOf :model/Table)]
metabase-database :- (ms/InstanceOf :model/Database)]
(let [name+schema->table-metadata (m/index-by (juxt :name :schema) table-metadatas)
name+schema->metabase-table (m/index-by (juxt :name :schema) metabase-tables)]
(doseq [name+schema (set/intersection (set (keys name+schema->table-metadata)) (set (keys name+schema->metabase-table)))]
(update-table-metadata-if-needed! (name+schema->table-metadata name+schema) (name+schema->metabase-table name+schema)))))
(update-table-metadata-if-needed! (name+schema->table-metadata name+schema)
(name+schema->metabase-table name+schema)
metabase-database))))
(mu/defn- table-set :- [:set i/DatabaseMetadataTable]
"So there exist tables for the user and metabase metadata tables for internal usage by metabase.
@@ -198,7 +227,8 @@
(mu/defn- db->our-metadata :- [:set (ms/InstanceOf :model/Table)]
"Return information about what Tables we have for this DB in the Metabase application DB."
[database :- i/DatabaseInstance]
(set (t2/select [:model/Table :id :name :schema :description :database_require_filter :estimated_row_count]
(set (t2/select [:model/Table :id :name :schema :description :database_require_filter :estimated_row_count
:visibility_type :initial_sync_status]
:db_id (u/the-id database)
:active true)))
@@ -211,34 +241,35 @@
([database :- i/DatabaseInstance db-metadata]
;; determine what's changed between what info we have and what's in the DB
(let [db-tables (table-set db-metadata)
name+schema #(select-keys % [:name :schema])
name+schema->db-table (m/index-by name+schema db-tables)
our-metadata (db->our-metadata database)
keep-name+schema-set (fn [metadata]
(set (map name+schema metadata)))
[new-tables old-tables] (data/diff
(keep-name+schema-set (set (map name+schema db-tables)))
(keep-name+schema-set (set (map name+schema our-metadata))))]
(let [db-table-metadatas (table-set db-metadata)
name+schema #(select-keys % [:name :schema])
name+schema->db-table (m/index-by name+schema db-table-metadatas)
our-metadata (db->our-metadata database)
keep-name+schema-set (fn [metadata]
(set (map name+schema metadata)))
[new-table-metadatas
old-table-metadatas] (data/diff
(keep-name+schema-set (set (map name+schema db-table-metadatas)))
(keep-name+schema-set (set (map name+schema our-metadata))))]
;; update database metadata from database
(when (some? (:version db-metadata))
(sync-util/with-error-handling (format "Error creating/reactivating tables for %s"
(sync-util/name-for-logging database))
(update-database-metadata! database db-metadata)))
;; create new tables as needed or mark them as active again
(when (seq new-tables)
(let [new-tables-info (set (map #(get name+schema->db-table (name+schema %)) new-tables))]
(when (seq new-table-metadatas)
(let [new-tables-info (set (map #(get name+schema->db-table (name+schema %)) new-table-metadatas))]
(sync-util/with-error-handling (format "Error creating/reactivating tables for %s"
(sync-util/name-for-logging database))
(create-or-reactivate-tables! database new-tables-info))))
;; mark old tables as inactive
(when (seq old-tables)
(when (seq old-table-metadatas)
(sync-util/with-error-handling (format "Error retiring tables for %s" (sync-util/name-for-logging database))
(retire-tables! database old-tables)))
(retire-tables! database old-table-metadatas)))
(sync-util/with-error-handling (format "Error updating table metadata for %s" (sync-util/name-for-logging database))
;; we need to fetch the tables again because we might have retired tables in the previous steps
(update-tables-metadata-if-needed! db-tables (db->our-metadata database)))
(update-tables-metadata-if-needed! db-table-metadatas (db->our-metadata database) database))
{:updated-tables (+ (count new-tables) (count old-tables))
{:updated-tables (+ (count new-table-metadatas) (count old-table-metadatas))
:total-tables (count our-metadata)})))

View File

@@ -891,21 +891,22 @@
(defn- can-delete-error
"Returns an ExceptionInfo object if the user cannot delete the given upload. Returns nil otherwise."
[table]
(cond
(not (:is_upload table))
(ex-info (tru "The table must be an uploaded table.")
{:status-code 422})
[table database]
(when-not (:is_attached_dwh database) ;; gsheets uploads: deletable, but we cannot write + they aren't is_upload
(cond
(not (:is_upload table))
(ex-info (tru "The table must be an uploaded table.")
{:status-code 422})
(not (mi/can-write? table))
(ex-info (tru "You don''t have permissions to do that.")
{:status-code 403})))
(not (mi/can-write? table))
(ex-info (tru "You don''t have permissions to do that.")
{:status-code 403}))))
(defn- check-can-delete
"Throws an error if the given table is not an upload, or if the user does not have permission to delete it."
[table]
[table database]
;; For now anyone that can update a table is allowed to delete it.
(when-let [error (can-delete-error table)]
(when-let [error (can-delete-error table database)]
(throw error)))
;;; +--------------------------------------------------
@@ -918,7 +919,7 @@
(let [database (table/database table)
driver (driver.u/database->driver database)
table-name (table-identifier table)]
(check-can-delete table)
(check-can-delete table database)
;; Attempt to delete the underlying data from the customer database.
;; We perform this before marking the table as inactive in the app db so that even if it false, the table is still

View File

@@ -150,8 +150,42 @@
:table_id transactions-table-id
:parent_id food-likes-field-id
:active true))]
;; now sync again.
(sync-metadata/sync-db-metadata! db)
;; field should become inactive
(is (false? (t2/select-one-fn :active :model/Field :id blueberries-field-id)))))))
(defn run-cruft-test [patterns freqs]
(mt/with-temp [:model/Database db {:engine ::toucanery/toucanery
:settings {:auto-cruft-columns patterns}}]
(sync-metadata/sync-db-metadata! db)
(let [tables (t2/select :model/Table :db_id (u/the-id db))
fields (mapcat (fn [{:keys [id]}] (t2/select :model/Field :table_id id)) tables)]
(is (= freqs
(frequencies (map :visibility_type fields)))))))
(deftest auto-cruft-all-fields-test
(testing "Make sure a db's settings.auto_cruft_fields mark all fields as crufty"
(run-cruft-test [".*"]
{:details-only 12})
(run-cruft-test (map str (into [] "abcdefghijklmnoqprstuvwxyz"))
{:details-only 12})))
(deftest auto-cruft-exact-field-name-test
(testing "Make sure a db's settings.auto_cruft_fields mark fields as crufty for exact table names"
(run-cruft-test ["^details$" "^age$"]
{:normal 10 :details-only 2})))
(deftest auto-cruft-fields-with-multiple-patterns-test
(testing "Make sure a db's settings.auto_cruft_fields mark fields as crufty against multiple patterns"
(run-cruft-test ["a" "b" "c"] {:normal 4 :details-only 8})
(run-cruft-test ["c" "a" "t"] {:normal 3 :details-only 9})
(run-cruft-test ["d" "o" "g"] {:normal 6 :details-only 6})
(run-cruft-test ["b" "i" "r" "d"] {:normal 7 :details-only 5})
(run-cruft-test ["x" "y" "z"] {:normal 11 :details-only 1})))
(deftest auto-cruft-fields-none-are-crufted-with-missing-pattern
(run-cruft-test [] {:normal 12}))
(deftest auto-cruft-fields-none-are-crufted-with-no-hit-pattern
(run-cruft-test ["^it was the best$" "^of times it was$" "^the worst of times$"] {:normal 12}))

View File

@@ -5,6 +5,7 @@
[metabase.sync.core :as sync]
[metabase.sync.sync-metadata.fields.sync-metadata :as sync-metadata]
[metabase.test :as mt]
[metabase.util :as u]
[next.jdbc :as next.jdbc]
[toucan2.core :as t2]))
@@ -13,12 +14,14 @@
;; use alphabetical field_order by default because the default, database, will update the position
(updates-that-will-be-performed! new-metadata-from-sync metadata-in-application-db {:field_order :alphabetical}))
([new-metadata-from-sync metadata-in-application-db table]
(mt/with-temp [:model/Table table table]
(mt/with-temp [:model/Database db {}
:model/Table table (assoc table :db_id (u/the-id db))]
(let [update-operations (atom [])]
(with-redefs [t2/update! (fn [model id updates]
(swap! update-operations conj [(name model) id updates])
(count updates))]
(#'sync-metadata/update-field-metadata-if-needed!
db
table
new-metadata-from-sync
metadata-in-application-db)

View File

@@ -8,11 +8,13 @@
[metabase.driver.sql-jdbc.connection :as sql-jdbc.conn]
[metabase.query-processor :as qp]
[metabase.sync.core :as sync]
[metabase.sync.sync-metadata :as sync-metadata]
[metabase.sync.sync-metadata.fields :as sync-fields]
[metabase.sync.sync-metadata.fks :as sync-fks]
[metabase.sync.util-test :as sync.util-test]
[metabase.test :as mt]
[metabase.test.data.one-off-dbs :as one-off-dbs]
[metabase.test.mock.toucanery :as toucanery]
[metabase.util :as u]
[toucan2.connection :as t2.connection]
[toucan2.core :as t2]))
@@ -338,3 +340,36 @@
:table_id [:in (map :id tables)])
distinct
(sort-by :name)))))))))))))
(defn db->fields [db]
(let [tables (t2/select :model/Table :db_id (u/the-id db))]
(mapcat (fn [table]
(t2/select :model/Field :table_id (u/the-id table)))
tables)))
(deftest auto-cruft-fields-with-an-l-test
(testing "Make sure a db's settings.auto-cruft-tables actually mark tables as crufty"
(mt/with-temp [:model/Database db {:engine ::toucanery/toucanery
:settings {:auto-cruft-columns ["l"]}}]
(sync-metadata/sync-db-metadata! db)
(is (= [["details" :details-only]]
(->> (db->fields db)
(filter #(= :details-only (:visibility_type %)))
(mapv (juxt :name :visibility_type))))))))
(deftest auto-cruft-all-fields-and-they-stay-crufted-test
(testing "Make sure a db's settings.auto-cruft-tables actually mark tables as crufty"
(mt/with-temp [:model/Database db {:engine ::toucanery/toucanery
:settings {:auto-cruft-columns [".*"]}}]
(sync-metadata/sync-db-metadata! db)
(is (= {:details-only 12}
(->> (db->fields db)
(mapv :visibility_type)
frequencies)))
;; remove cruft column directive:
(t2/update! :model/Database (u/the-id db) {:settings {:auto-cruft-columns []}})
(sync-metadata/sync-db-metadata! db)
(is (= {:details-only 12}
(->> (db->fields db)
(mapv :visibility_type)
frequencies))))))

View File

@@ -7,15 +7,17 @@
[metabase.driver.sql-jdbc.connection :as sql-jdbc.conn]
[metabase.driver.sql-jdbc.execute :as sql-jdbc.execute]
[metabase.sync.core :as sync]
[metabase.sync.sync-metadata :as sync-metadata]
[metabase.sync.sync-metadata.tables :as sync-tables]
[metabase.test :as mt]
[metabase.test.data.interface :as tx]
[metabase.test.data.sql :as sql.tx]
[metabase.test.mock.toucanery :as toucanery]
[metabase.util :as u]
[next.jdbc :as next.jdbc]
[toucan2.core :as t2]))
(tx/defdataset db-with-some-cruft
(tx/defdataset db-with-a-crufty-table
[["acquired_toucans"
[{:field-name "species" :base-type :type/Text}
{:field-name "cam_has_acquired_one" :base-type :type/Boolean}]
@@ -31,9 +33,9 @@
(deftest crufty-tables-test
(testing "south_migrationhistory, being a CRUFTY table, should still be synced, but marked as such"
(mt/dataset metabase.sync.sync-metadata.tables-test/db-with-some-cruft
(mt/dataset metabase.sync.sync-metadata.tables-test/db-with-a-crufty-table
(is (= #{{:name "SOUTH_MIGRATIONHISTORY" :visibility_type :cruft :initial_sync_status "complete"}
{:name "ACQUIRED_TOUCANS" :visibility_type nil :initial_sync_status "complete"}}
{:name "ACQUIRED_TOUCANS" :visibility_type nil :initial_sync_status "complete"}}
(set (for [table (t2/select [:model/Table :name :visibility_type :initial_sync_status] :db_id (mt/id))]
(into {} table))))))))
@@ -77,3 +79,51 @@
(sync/sync-database! (mt/db) {:scan :schema})
(is (= 100
(t2/select-one-fn :estimated_row_count :model/Table (mt/id :venues))))))))
(defmacro ^:private run-twice
"Run body twice. In this case, we are checking that creating and updating syncs the tables correctly w.r.t.
cruftiness."
[& body]
`(do ~@body ~@body))
(deftest auto-cruft-all-tables-test
(testing "Make sure a db's settings.auto-cruft-tables actually mark tables as crufty"
(mt/with-temp [:model/Database db {:engine ::toucanery/toucanery
:settings {:auto-cruft-tables [".*"]}}]
(run-twice
(sync-metadata/sync-db-metadata! db)
(is (= #{:cruft}
(t2/select-fn-set :visibility_type
:model/Table
:db_id
(u/the-id db))))))))
(deftest auto-cruft-employee-table-test
(testing "Make sure a db's settings.auto-cruft-tables actually mark tables as crufty"
(mt/with-temp [:model/Database db {:engine ::toucanery/toucanery
:settings {:auto-cruft-tables ["employees"]}}]
(run-twice
(sync-metadata/sync-db-metadata! db)
(is (= #{["employees" :cruft]
["transactions" nil]}
(t2/select-fn-set (juxt :name :visibility_type) :model/Table :db_id (u/the-id db))))))))
(deftest auto-cruft-tables-with-an-l-test
(testing "Make sure a db's settings.auto-cruft-tables actually mark tables as crufty"
(mt/with-temp [:model/Database db {:engine ::toucanery/toucanery
:settings {:auto-cruft-tables ["l"]}}]
(run-twice
(sync-metadata/sync-db-metadata! db)
(is (= #{["employees" :cruft]
["transactions" nil]}
(t2/select-fn-set (juxt :name :visibility_type) :model/Table :db_id (u/the-id db))))))))
(deftest auto-cruft-tables-with-an-l-or-a-y-test
(testing "Make sure a db's settings.auto-cruft-tables actually mark tables as crufty"
(mt/with-temp [:model/Database db {:engine ::toucanery/toucanery
:settings {:auto-cruft-tables ["l" "y"]}}]
(run-twice
(sync-metadata/sync-db-metadata! db)
(is (= #{["employees" :cruft]
["transactions" nil]}
(t2/select-fn-set (juxt :name :visibility_type) :model/Table :db_id (u/the-id db))))))))

View File

@@ -0,0 +1,167 @@
;; method + url + body -> response from [[metabase-enterprise.harbormaster.client/make-request]]
{{:method :delete,
;; uuid matches the one from get connections:
:url "/api/v2/mb/connections/049f3007-2146-4083-be38-f160c526aca7",
:body nil}
[:ok {:cached nil,
:request-time 945,
:repeatable? false,
:protocol-version {:name "HTTP", :major 1, :minor 1},
:streaming? false,
;; :http-client #object[org.apache.http.impl.client.InternalHttpClient 0x61435626 "org.apache.http.impl.client.InternalHttpClient@61435626"],
:chunked? false,
:reason-phrase "OK",
:headers
{"Date" "Mon, 27 Jan 2025 21:45:48 GMT",
"Connection" "close",
"Content-Type" "application/octet-stream",
"X-Frame-Options" "SAMEORIGIN",
"X-Content-Type-Options" "nosniff",
"Strict-Transport-Security" "max-age=31536000; includeSubDomains",
"Content-Length" "0",
"Server" "Jetty(11.0.24)"},
:orig-content-encoding nil,
:status 200,
:length 0,
:body nil,
:trace-redirects []}]
{:method :get, :url "/api/v2/mb/connections", :body nil},
[:ok {:cached nil,
:request-time 23,
:repeatable? false,
:protocol-version {:name "HTTP", :major 1, :minor 1},
:streaming? true,
;; :http-client #object[org.apache.http.impl.client.InternalHttpClient 0x8f60f32 "org.apache.http.impl.client.InternalHttpClient@8f60f32"],
:chunked? false,
:reason-phrase "OK",
:headers
{"Date" "Mon, 27 Jan 2025 18:43:42 GMT",
"Connection" "close",
"Content-Type" "application/json;charset=utf-8",
"X-Frame-Options" "SAMEORIGIN",
"X-Content-Type-Options" "nosniff",
"Strict-Transport-Security" "max-age=31536000; includeSubDomains",
"Content-Length" "349",
"Server" "Jetty(11.0.24)"},
:orig-content-encoding nil,
:status 200,
:length 349,
:body
[{:updated-at "2025-01-27T18:43:42Z",
:hosted-instance-resource-id 7,
:last-sync-at "2025-01-27T18:43:42Z",
:error-detail nil,
:type "gdrive",
:hosted-instance-id "f390ec19-bd44-48ae-991c-66817182a376",
:last-sync-started-at "2025-01-27T18:43:04Z",
:status "active",
:id "049f3007-2146-4083-be38-f160c526aca7",
:created-at "2025-01-27T18:43:02Z"},]
:trace-redirects []}]
{:method :get, :url "/api/v2/mb/connections/049f3007-2146-4083-be38-f160c526aca7", :body nil},
[:ok {:cached nil,
:request-time 23,
:repeatable? false,
:protocol-version {:name "HTTP", :major 1, :minor 1},
:streaming? true,
;; :http-client #object[org.apache.http.impl.client.InternalHttpClient 0x8f60f32 "org.apache.http.impl.client.InternalHttpClient@8f60f32"],
:chunked? false,
:reason-phrase "OK",
:headers
{"Date" "Mon, 27 Jan 2025 18:43:42 GMT",
"Connection" "close",
"Content-Type" "application/json;charset=utf-8",
"X-Frame-Options" "SAMEORIGIN",
"X-Content-Type-Options" "nosniff",
"Strict-Transport-Security" "max-age=31536000; includeSubDomains",
"Content-Length" "349",
"Server" "Jetty(11.0.24)"},
:orig-content-encoding nil,
:status 200,
:length 349,
:body {:updated-at "2025-01-27T18:43:42Z",
:hosted-instance-resource-id 7,
:last-sync-at "2025-01-27T18:43:42Z",
:error-detail nil,
:type "gdrive",
:hosted-instance-id "f390ec19-bd44-48ae-991c-66817182a376",
:last-sync-started-at "2025-01-27T18:43:04Z",
:status "active",
:id "049f3007-2146-4083-be38-f160c526aca7",
:created-at "2025-01-27T18:43:02Z"},
:trace-redirects []}]
{:method :post,
:url "/api/v2/mb/connections",
:body {:type "gdrive", :secret {:resources ["<expected-gdrive-link>"]}}}
[:ok {:cached nil,
:request-time 1941,
:repeatable? false,
:protocol-version {:name "HTTP", :major 1, :minor 1},
:streaming? true,
;; :http-client #object[org.apache.http.impl.client.InternalHttpClient 0x5b19fc1e "org.apache.http.impl.client.InternalHttpClient@5b19fc1e"],
:chunked? false,
:reason-phrase "OK",
:headers
{"Date" "Mon, 27 Jan 2025 18:43:02 GMT",
"Connection" "close",
"Content-Type" "application/json;charset=utf-8",
"X-Frame-Options" "SAMEORIGIN",
"X-Content-Type-Options" "nosniff",
"Strict-Transport-Security" "max-age=31536000; includeSubDomains",
"Content-Length" "779",
"Server" "Jetty(11.0.24)"},
:orig-content-encoding nil,
:status 200,
:length 779,
:body
{:updated-at "2025-01-27T18:43:02Z",
:hosted-instance-resource-id 7,
:last-sync-at nil,
:error-detail nil,
:type "gdrive",
:hosted-instance-resource
{:updated-at "2025-01-27T18:43:02Z",
:shared-resource-id nil,
:resource-id
"arn:aws:secretsmanager:us-west-2:373519213707:secret:hosting/development/instance/f390ec19-bd44-48ae-991c-66817182a376/conn-secret/049f3007-2146-4083-be38-f160c526aca7-C6UvyD",
:type "conn-secret",
:hosted-instance-id "f390ec19-bd44-48ae-991c-66817182a376",
:deleted-at nil,
:region "us-west-2",
:id 7,
:created-at "2025-01-27T18:43:02Z",
:provider "k8s"},
:hosted-instance-id "f390ec19-bd44-48ae-991c-66817182a376",
:last-sync-started-at nil,
:status "initializing",
:id "049f3007-2146-4083-be38-f160c526aca7",
:created-at "2025-01-27T18:43:02Z"},
:trace-redirects []}]
{:method :get,
:url "/api/v2/mb/connections-google/service-account"
:body nil}
[:ok {:cached nil,
:request-time 12,
:repeatable? false,
:protocol-version {:name "HTTP", :major 1, :minor 1},
:streaming? true,
;; :http-client #object[org.apache.http.impl.client.InternalHttpClient 0x4b1202cb "org.apache.http.impl.client.InternalHttpClient@4b1202cb"],
:chunked? false,
:reason-phrase "OK",
:headers
{"Date" "Mon, 27 Jan 2025 18:50:57 GMT",
"Connection" "close",
"Content-Type" "application/json;charset=utf-8",
"X-Frame-Options" "SAMEORIGIN",
"X-Content-Type-Options" "nosniff",
"Strict-Transport-Security" "max-age=31536000; includeSubDomains",
"Content-Length" "60",
"Server" "Jetty(11.0.24)"},
:orig-content-encoding nil,
:status 200,
:length 60,
:body {:email "service-account@elt-sync.iam.gserviceaccount.com"},
:trace-redirects []}]}

View File

@@ -22,6 +22,7 @@ enable-pivoted-exports: null
enable-sandboxes?: null
enable-whitelabeling?: null
enable-xrays: null
gsheets: null
hide-embed-branding?: null
humanization-strategy: null
landing-page: null