diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 14c0cc06a3..792191af7a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,8 +52,8 @@ jobs: mac_certs: ${{ secrets.mac_certs }} mac_certs_password: ${{ secrets.mac_certs_password }} - # windows_certs: ${{ secrets.windows_certs }} - # windows_certs_password: ${{ secrets.windows_certs_password }} + windows_certs: ${{ secrets.windows_certs }} + windows_certs_password: ${{ secrets.windows_certs_password }} # release the app after building release: ${{ startsWith(github.ref, 'refs/tags/v') }} diff --git a/.gitignore b/.gitignore index b6cfa424ad..a84d25d46d 100644 --- a/.gitignore +++ b/.gitignore @@ -83,4 +83,7 @@ typings/ .DS_Store # Nova Editor -.nova \ No newline at end of file +.nova + +# swap files (linux) +*.swp diff --git a/hosting/build/docker-compose.yaml b/hosting/build/docker-compose.yaml deleted file mode 100644 index 6988e3841b..0000000000 --- a/hosting/build/docker-compose.yaml +++ /dev/null @@ -1,16 +0,0 @@ -version: "3" - -services: - app-service: - build: ./server - volumes: - - ./server:/app - environment: - SELF_HOSTED: 1 - PORT: 4002 - - worker-service: - build: ./worker - environment: - SELF_HOSTED: 1, - PORT: 4003 diff --git a/hosting/build/server b/hosting/build/server deleted file mode 120000 index c40730cce5..0000000000 --- a/hosting/build/server +++ /dev/null @@ -1 +0,0 @@ -../../packages/server/ \ No newline at end of file diff --git a/hosting/build/worker b/hosting/build/worker deleted file mode 120000 index 8582fefbee..0000000000 --- a/hosting/build/worker +++ /dev/null @@ -1 +0,0 @@ -../../packages/worker/ \ No newline at end of file diff --git a/hosting/docker-compose.dev.yaml b/hosting/docker-compose.dev.yaml new file mode 100644 index 0000000000..8ee1753e11 --- /dev/null +++ b/hosting/docker-compose.dev.yaml @@ -0,0 +1,73 @@ +version: "3" + +# optional ports are specified throughout for more advanced use cases. + +services: + minio-service: + container_name: budi-minio-dev + restart: always + image: minio/minio + volumes: + - minio_data:/data + ports: + - "${MINIO_PORT}:9000" + environment: + MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY} + MINIO_SECRET_KEY: ${MINIO_SECRET_KEY} + MINIO_BROWSER: "off" + command: server /data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 30s + timeout: 20s + retries: 3 + + proxy-service: + container_name: budi-envoy-dev + restart: always + image: envoyproxy/envoy:v1.16-latest + volumes: + - ./envoy.dev.yaml:/etc/envoy/envoy.yaml + ports: + - "${MAIN_PORT}:10000" + depends_on: + - minio-service + - couchdb-service + + couchdb-service: + container_name: budi-couchdb-dev + restart: always + image: ibmcom/couchdb3 + environment: + - COUCHDB_PASSWORD=${COUCH_DB_PASSWORD} + - COUCHDB_USER=${COUCH_DB_USER} + ports: + - "${COUCH_DB_PORT}:5984" + volumes: + - couchdb3_data:/opt/couchdb/data + + couch-init: + container_name: budi-couchdb-init-dev + image: curlimages/curl + environment: + PUT_CALL: "curl -u ${COUCH_DB_USER}:${COUCH_DB_PASSWORD} -X PUT couchdb-service:5984" + depends_on: + - couchdb-service + command: ["sh","-c","sleep 10 && $${PUT_CALL}/_users && $${PUT_CALL}/_replicator; fg;"] + + redis-service: + container_name: budi-redis-dev + restart: always + image: redis + ports: + - "${REDIS_PORT}:6379" + volumes: + - redis_data:/data + +volumes: + couchdb3_data: + driver: local + minio_data: + driver: local + redis_data: + driver: local diff --git a/hosting/docker-compose.yaml b/hosting/docker-compose.yaml index e8408d9a7d..8de5e9fcdd 100644 --- a/hosting/docker-compose.yaml +++ b/hosting/docker-compose.yaml @@ -11,13 +11,18 @@ services: - "${APP_PORT}:4002" environment: SELF_HOSTED: 1 - CLOUD: 1 COUCH_DB_URL: http://${COUCH_DB_USER}:${COUCH_DB_PASSWORD}@couchdb-service:5984 WORKER_URL: http://worker-service:4003 + MINIO_URL: http://minio-service:9000 + MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY} + MINIO_SECRET_KEY: ${MINIO_SECRET_KEY} HOSTING_KEY: ${HOSTING_KEY} BUDIBASE_ENVIRONMENT: ${BUDIBASE_ENVIRONMENT} PORT: 4002 JWT_SECRET: ${JWT_SECRET} + LOG_LEVEL: info + SENTRY_DSN: https://a34ae347621946bf8acded18e5b7d4b8@o420233.ingest.sentry.io/5338131 + ENABLE_ANALYTICS: true depends_on: - worker-service @@ -28,7 +33,7 @@ services: ports: - "${WORKER_PORT}:4003" environment: - SELF_HOSTED: 1, + SELF_HOSTED: 1 PORT: 4003 MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY} MINIO_SECRET_KEY: ${MINIO_SECRET_KEY} @@ -66,7 +71,6 @@ services: - ./envoy.yaml:/etc/envoy/envoy.yaml ports: - "${MAIN_PORT}:10000" - #- "9901:9901" depends_on: - minio-service - worker-service @@ -75,16 +79,14 @@ services: couchdb-service: restart: always - image: apache/couchdb:3.0 + image: ibmcom/couchdb3 environment: - COUCHDB_PASSWORD=${COUCH_DB_PASSWORD} - COUCHDB_USER=${COUCH_DB_USER} ports: - "${COUCH_DB_PORT}:5984" - #- "4369:4369" - #- "9100:9100" volumes: - - couchdb_data:/opt/couchdb/data + - couchdb3_data:/opt/couchdb/data couch-init: image: curlimages/curl @@ -93,9 +95,19 @@ services: depends_on: - couchdb-service command: ["sh","-c","sleep 10 && $${PUT_CALL}/_users && $${PUT_CALL}/_replicator; fg;"] + + redis-service: + restart: always + image: redis + ports: + - "${REDIS_PORT}:6379" + volumes: + - redis_data:/data volumes: - couchdb_data: + couchdb3_data: driver: local minio_data: driver: local + redis_data: + driver: local diff --git a/hosting/envoy.dev.yaml b/hosting/envoy.dev.yaml new file mode 100644 index 0000000000..e12bc2c0e0 --- /dev/null +++ b/hosting/envoy.dev.yaml @@ -0,0 +1,79 @@ +static_resources: + listeners: + - name: main_listener + address: + socket_address: { address: 0.0.0.0, port_value: 10000 } + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress + codec_type: auto + route_config: + name: local_route + virtual_hosts: + - name: local_services + domains: ["*"] + routes: + - match: { prefix: "/db/" } + route: + cluster: couchdb-service + prefix_rewrite: "/" + + - match: { prefix: "/cache/" } + route: + cluster: redis-service + prefix_rewrite: "/" + + # minio is on the default route because this works + # best, minio + AWS SDK doesn't handle path proxy + - match: { prefix: "/" } + route: + cluster: minio-service + + http_filters: + - name: envoy.filters.http.router + + clusters: + - name: minio-service + connect_timeout: 0.25s + type: strict_dns + lb_policy: round_robin + load_assignment: + cluster_name: minio-service + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: minio-service + port_value: 9000 + + - name: couchdb-service + connect_timeout: 0.25s + type: strict_dns + lb_policy: round_robin + load_assignment: + cluster_name: couchdb-service + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: couchdb-service + port_value: 5984 + + - name: redis-service + connect_timeout: 0.25s + type: strict_dns + lb_policy: round_robin + load_assignment: + cluster_name: redis-service + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: redis-service + port_value: 6379 diff --git a/hosting/envoy.yaml b/hosting/envoy.yaml index 11f5c81b99..8c6081d1a7 100644 --- a/hosting/envoy.yaml +++ b/hosting/envoy.yaml @@ -36,6 +36,11 @@ static_resources: cluster: worker-service prefix_rewrite: "/" + - match: { prefix: "/cache/" } + route: + cluster: redis-service + prefix_rewrite: "/" + - match: { prefix: "/db/" } route: cluster: couchdb-service @@ -107,3 +112,18 @@ static_resources: address: couchdb-service port_value: 5984 + - name: redis-service + connect_timeout: 0.25s + type: strict_dns + lb_policy: round_robin + load_assignment: + cluster_name: redis-service + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: redis-service + port_value: 6379 + + diff --git a/hosting/hosting.properties b/hosting/hosting.properties index ad047a3826..138e66d629 100644 --- a/hosting/hosting.properties +++ b/hosting/hosting.properties @@ -18,4 +18,5 @@ APP_PORT=4002 WORKER_PORT=4003 MINIO_PORT=4004 COUCH_DB_PORT=4005 +REDIS_PORT=6379 BUDIBASE_ENVIRONMENT=PRODUCTION diff --git a/packages/builder/cypress.json b/packages/builder/cypress.json index 09b38ae985..84c50ae863 100644 --- a/packages/builder/cypress.json +++ b/packages/builder/cypress.json @@ -3,6 +3,7 @@ "video": true, "projectId": "bmbemn", "env": { - "PORT": "4001" + "PORT": "4001", + "JWT_SECRET": "test" } } diff --git a/packages/builder/cypress/setup.js b/packages/builder/cypress/setup.js index 6848d38cba..7b849eb887 100644 --- a/packages/builder/cypress/setup.js +++ b/packages/builder/cypress/setup.js @@ -1,25 +1,26 @@ -// What this script does: -// 1. Removes the old test folder if it exists (.budibase) -// 2. Initialises using `.budibase` -// 3. Runs the server using said folder - -const { join, resolve } = require("path") -const initialiseBudibase = require("../../server/src/utilities/initialiseBudibase") const cypressConfig = require("../cypress.json") +const path = require("path") -const homedir = join(require("os").homedir(), ".budibase") +const tmpdir = path.join(require("os").tmpdir(), ".budibase") process.env.BUDIBASE_API_KEY = "6BE826CB-6B30-4AEC-8777-2E90464633DE" process.env.NODE_ENV = "cypress" process.env.ENABLE_ANALYTICS = "false" process.env.PORT = cypressConfig.env.PORT +process.env.JWT_SECRET = cypressConfig.env.JWT_SECRET +process.env.COUCH_URL = `leveldb://${tmpdir}/.data/` +process.env.SELF_HOSTED = 1 +process.env.MINIO_URL = "http://localhost:10000/" +process.env.MINIO_ACCESS_KEY = "budibase" +process.env.MINIO_SECRET_KEY = "budibase" +process.env.COUCH_DB_USER = "budibase" +process.env.COUCH_DB_PASSWORD = "budibase" // Stop info logs polluting test outputs process.env.LOG_LEVEL = "error" -async function run(dir) { - process.env.BUDIBASE_DIR = resolve(dir) - require("dotenv").config({ path: resolve(dir, ".env") }) +async function run() { + // require("dotenv").config({ path: resolve(dir, ".env") }) // dont make this a variable or top level require // it will cause environment module to be loaded prematurely @@ -27,12 +28,15 @@ async function run(dir) { server.on("close", () => console.log("Server Closed")) } -initialiseBudibase({ dir: homedir, clientId: "cypress-test" }) - .then(() => { - delete require.cache[require.resolve("../../server/src/environment")] - const xPlatHomeDir = homedir.startsWith("~") - ? join(homedir(), homedir.substring(1)) - : homedir - run(xPlatHomeDir) - }) - .catch(e => console.error(e)) +run() + +// TODO: ensure that this still works +// initialiseBudibase({ dir: homedir, clientId: "cypress-test" }) +// .then(() => { +// delete require.cache[require.resolve("../../server/src/environment")] +// const xPlatHomeDir = homedir.startsWith("~") +// ? join(homedir(), homedir.substring(1)) +// : homedir +// run(xPlatHomeDir) +// }) +// .catch(e => console.error(e)) diff --git a/packages/builder/src/builderStore/store/backend.js b/packages/builder/src/builderStore/store/backend.js new file mode 100644 index 0000000000..5d0e722650 --- /dev/null +++ b/packages/builder/src/builderStore/store/backend.js @@ -0,0 +1,362 @@ +import { writable, get } from "svelte/store" +import { cloneDeep } from "lodash/fp" +import api from "../api" + +const INITIAL_BACKEND_UI_STATE = { + tables: [], + views: [], + users: [], + roles: [], + datasources: [], + queries: [], + integrations: {}, + selectedDatabase: {}, + selectedTable: {}, + draftTable: {}, +} + +export const getBackendUiStore = () => { + const store = writable({ ...INITIAL_BACKEND_UI_STATE }) + + store.actions = { + reset: () => store.set({ ...INITIAL_BACKEND_UI_STATE }), + database: { + select: async db => { + const tablesResponse = await api.get(`/api/tables`) + const tables = await tablesResponse.json() + const datasourcesResponse = await api.get(`/api/datasources`) + const datasources = await datasourcesResponse.json() + const queriesResponse = await api.get(`/api/queries`) + const queries = await queriesResponse.json() + const integrationsResponse = await api.get("/api/integrations") + const integrations = await integrationsResponse.json() + const permissionLevels = await store.actions.permissions.fetchLevels() + + store.update(state => { + state.selectedDatabase = db + state.tables = tables + state.datasources = datasources + state.queries = queries + state.integrations = integrations + state.permissionLevels = permissionLevels + return state + }) + }, + }, + rows: { + save: () => + store.update(state => { + state.selectedView = state.selectedView + return state + }), + delete: () => + store.update(state => { + state.selectedView = state.selectedView + return state + }), + select: row => + store.update(state => { + state.selectedRow = row + return state + }), + }, + datasources: { + fetch: async () => { + const response = await api.get(`/api/datasources`) + const json = await response.json() + store.update(state => { + state.datasources = json + return state + }) + return json + }, + select: async datasourceId => { + store.update(state => { + state.selectedDatasourceId = datasourceId + state.selectedQueryId = null + return state + }) + }, + save: async datasource => { + const response = await api.post("/api/datasources", datasource) + const json = await response.json() + store.update(state => { + const currentIdx = state.datasources.findIndex( + ds => ds._id === json._id + ) + + if (currentIdx >= 0) { + state.datasources.splice(currentIdx, 1, json) + } else { + state.datasources.push(json) + } + + state.datasources = state.datasources + state.selectedDatasourceId = json._id + return state + }) + return json + }, + delete: async datasource => { + await api.delete( + `/api/datasources/${datasource._id}/${datasource._rev}` + ) + store.update(state => { + state.datasources = state.datasources.filter( + existing => existing._id !== datasource._id + ) + if (datasource._id === state.selectedDatasourceId) { + state.selectedDatasourceId = null + } + return state + }) + }, + }, + queries: { + fetch: async () => { + const response = await api.get(`/api/queries`) + const json = await response.json() + store.update(state => { + state.queries = json + return state + }) + return json + }, + save: async (datasourceId, query) => { + const integrations = get(store).integrations + const dataSource = get(store).datasources.filter( + ds => ds._id === datasourceId + ) + // check if readable attribute is found + if (dataSource.length !== 0) { + const integration = integrations[dataSource[0].source] + const readable = integration.query[query.queryVerb].readable + if (readable) { + query.readable = readable + } + } + query.datasourceId = datasourceId + const response = await api.post(`/api/queries`, query) + if (response.status !== 200) { + throw new Error("Failed saving query.") + } + const json = await response.json() + store.update(state => { + const currentIdx = state.queries.findIndex( + query => query._id === json._id + ) + + if (currentIdx >= 0) { + state.queries.splice(currentIdx, 1, json) + } else { + state.queries.push(json) + } + + state.queries = state.queries + state.selectedQueryId = json._id + return state + }) + return json + }, + select: query => + store.update(state => { + state.selectedDatasourceId = query.datasourceId + state.selectedQueryId = query._id + return state + }), + delete: async query => { + await api.delete(`/api/queries/${query._id}/${query._rev}`) + store.update(state => { + state.queries = state.queries.filter( + existing => existing._id !== query._id + ) + if (state.selectedQueryId === query._id) { + state.selectedQueryId = null + } + + return state + }) + }, + }, + tables: { + fetch: async () => { + const tablesResponse = await api.get(`/api/tables`) + const tables = await tablesResponse.json() + store.update(state => { + state.tables = tables + return state + }) + }, + select: table => + store.update(state => { + state.selectedTable = table + state.draftTable = cloneDeep(table) + state.selectedView = { name: `all_${table._id}` } + return state + }), + save: async table => { + const updatedTable = cloneDeep(table) + const oldTable = get(store).tables.filter(t => t._id === table._id)[0] + + const fieldNames = [] + // update any renamed schema keys to reflect their names + for (let key of Object.keys(updatedTable.schema)) { + // if field name has been seen before remove it + if (fieldNames.indexOf(key.toLowerCase()) !== -1) { + delete updatedTable.schema[key] + continue + } + const field = updatedTable.schema[key] + const oldField = oldTable?.schema[key] + // if the type has changed then revert back to the old field + if (oldField != null && oldField.type !== field.type) { + updatedTable.schema[key] = oldField + } + // field has been renamed + if (field.name && field.name !== key) { + updatedTable.schema[field.name] = field + updatedTable._rename = { old: key, updated: field.name } + delete updatedTable.schema[key] + } + // finally record this field has been used + fieldNames.push(key.toLowerCase()) + } + + const SAVE_TABLE_URL = `/api/tables` + const response = await api.post(SAVE_TABLE_URL, updatedTable) + const savedTable = await response.json() + await store.actions.tables.fetch() + store.actions.tables.select(savedTable) + return savedTable + }, + delete: async table => { + await api.delete(`/api/tables/${table._id}/${table._rev}`) + store.update(state => { + state.tables = state.tables.filter( + existing => existing._id !== table._id + ) + if (table._id === state.selectedTable._id) { + state.selectedTable = {} + } + return state + }) + }, + saveField: ({ originalName, field, primaryDisplay = false, indexes }) => { + store.update(state => { + // delete the original if renaming + // need to handle if the column had no name, empty string + if (originalName || originalName === "") { + delete state.draftTable.schema[originalName] + state.draftTable._rename = { + old: originalName, + updated: field.name, + } + } + + // Optionally set display column + if (primaryDisplay) { + state.draftTable.primaryDisplay = field.name + } + + if (indexes) { + state.draftTable.indexes = indexes + } + + state.draftTable.schema[field.name] = cloneDeep(field) + store.actions.tables.save(state.draftTable) + return state + }) + }, + deleteField: field => { + store.update(state => { + delete state.draftTable.schema[field.name] + store.actions.tables.save(state.draftTable) + return state + }) + }, + }, + views: { + select: view => + store.update(state => { + state.selectedView = view + state.selectedTable = {} + return state + }), + delete: async view => { + await api.delete(`/api/views/${view}`) + await store.actions.tables.fetch() + }, + save: async view => { + const response = await api.post(`/api/views`, view) + const json = await response.json() + + const viewMeta = { + name: view.name, + ...json, + } + + store.update(state => { + const viewTable = state.tables.find( + table => table._id === view.tableId + ) + + if (view.originalName) delete viewTable.views[view.originalName] + viewTable.views[view.name] = viewMeta + + state.tables = state.tables + state.selectedView = viewMeta + return state + }) + }, + }, + users: { + create: user => + store.update(state => { + state.users.push(user) + state.users = state.users + return state + }), + }, + roles: { + fetch: async () => { + const response = await api.get("/api/roles") + const roles = await response.json() + store.update(state => { + state.roles = roles + return state + }) + }, + delete: async role => { + const response = await api.delete(`/api/roles/${role._id}/${role._rev}`) + await store.actions.roles.fetch() + return response + }, + save: async role => { + const response = await api.post("/api/roles", role) + await store.actions.roles.fetch() + return response + }, + }, + permissions: { + fetchLevels: async () => { + const response = await api.get("/api/permission/levels") + const json = await response.json() + return json + }, + forResource: async resourceId => { + const response = await api.get(`/api/permission/${resourceId}`) + const json = await response.json() + return json + }, + save: async ({ role, resource, level }) => { + const response = await api.post( + `/api/permission/${role}/${resource}/${level}` + ) + const json = await response.json() + return json + }, + }, + } + + return store +} diff --git a/packages/builder/src/components/automation/AutomationPanel/EditAutomationPopover.svelte b/packages/builder/src/components/automation/AutomationPanel/EditAutomationPopover.svelte index cf5560ed26..df72b7feba 100644 --- a/packages/builder/src/components/automation/AutomationPanel/EditAutomationPopover.svelte +++ b/packages/builder/src/components/automation/AutomationPanel/EditAutomationPopover.svelte @@ -1,4 +1,4 @@ - diff --git a/packages/builder/src/components/backend/DatasourceNavigator/popovers/EditDatasourcePopover.svelte b/packages/builder/src/components/backend/DatasourceNavigator/popovers/EditDatasourcePopover.svelte index 53ad3b90d6..fe3998e137 100644 --- a/packages/builder/src/components/backend/DatasourceNavigator/popovers/EditDatasourcePopover.svelte +++ b/packages/builder/src/components/backend/DatasourceNavigator/popovers/EditDatasourcePopover.svelte @@ -22,9 +22,13 @@ } async function deleteDatasource() { + const wasSelectedSource = $datasources.selected await datasources.delete(datasource) notifier.success("Datasource deleted") - $goto('./datasource') + // navigate to first index page if the source you are deleting is selected + if (wasSelectedSource === datasource._id) { + $goto("./datasource") + } hideEditor() } diff --git a/packages/builder/src/components/backend/TableNavigator/popovers/EditTablePopover.svelte b/packages/builder/src/components/backend/TableNavigator/popovers/EditTablePopover.svelte index 108b61d168..fab7d4a3ee 100644 --- a/packages/builder/src/components/backend/TableNavigator/popovers/EditTablePopover.svelte +++ b/packages/builder/src/components/backend/TableNavigator/popovers/EditTablePopover.svelte @@ -1,4 +1,5 @@ - + - \ No newline at end of file diff --git a/packages/builder/src/pages/[application]/automate/index.svelte b/packages/builder/src/pages/[application]/automate/index.svelte index 98e5b80a18..f6568417a8 100644 --- a/packages/builder/src/pages/[application]/automate/index.svelte +++ b/packages/builder/src/pages/[application]/automate/index.svelte @@ -1,18 +1,19 @@ Create your first automation to get started @@ -23,4 +24,4 @@ color: var(--grey-5); margin-top: 2px; } - \ No newline at end of file + diff --git a/packages/builder/src/pages/[application]/data/_layout.svelte b/packages/builder/src/pages/[application]/data/_layout.svelte index 0674c93c88..5a31d7db92 100644 --- a/packages/builder/src/pages/[application]/data/_layout.svelte +++ b/packages/builder/src/pages/[application]/data/_layout.svelte @@ -17,11 +17,11 @@ }, ] - let tab = $isActive('./datasource') ? "datasource" : "table" + let tab = $isActive("./datasource") ? "datasource" : "table" function selectFirstTableOrSource({ detail }) { const type = detail.heading.key - if (type === 'datasource') { + if (type === "datasource") { $goto("./datasource") } else { $goto("./table") @@ -34,7 +34,10 @@