diff --git a/.eslintrc.json b/.eslintrc.json index 9d747b879..691ae90c7 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -2,15 +2,9 @@ "env": { "browser": true, "es2020": true, - "node": true + "node": true, + "jest": true }, - "extends": [ - "eslint:recommended", - "plugin:prettier/recommended", - "plugin:import/recommended", - "plugin:@typescript-eslint/recommended", - "next" - ], "parser": "@typescript-eslint/parser", "parserOptions": { "ecmaFeatures": { @@ -19,29 +13,29 @@ "ecmaVersion": 11, "sourceType": "module" }, - "plugins": ["@typescript-eslint", "prettier"], "settings": { "import/resolver": { - "alias": { - "map": [ - ["assets", "./src/assets"], - ["components", "./src/components"], - ["db", "./db"], - ["hooks", "./src/components/hooks"], - ["lang", "./src/lang"], - ["lib", "./src/lib"], - ["public", "./public"], - ["queries", "./src/queries"], - ["store", "./src/store"], - ["styles", "./src/styles"] - ], - "extensions": [".ts", ".tsx", ".js", ".jsx", ".json"] + "node": { + "moduleDirectory": ["node_modules", "src/"] } } }, + "extends": [ + "plugin:@typescript-eslint/recommended", + "eslint:recommended", + "plugin:prettier/recommended", + "plugin:import/errors", + "plugin:import/typescript", + "plugin:css-modules/recommended", + "plugin:cypress/recommended", + "prettier", + "next" + ], + "plugins": ["@typescript-eslint", "prettier", "promise", "css-modules", "cypress"], "rules": { "no-console": "error", "react/display-name": "off", + "react-hooks/exhaustive-deps": "off", "react/react-in-jsx-scope": "off", "react/prop-types": "off", "import/no-anonymous-default-export": "off", diff --git a/.github/ISSUE_TEMPLATE/1.bug_report.yml b/.github/ISSUE_TEMPLATE/1.bug_report.yml index db8be2102..711468f25 100644 --- a/.github/ISSUE_TEMPLATE/1.bug_report.yml +++ b/.github/ISSUE_TEMPLATE/1.bug_report.yml @@ -1,4 +1,4 @@ -name: "🐛 Bug Report" +name: '🐛 Bug Report' description: Create a bug report for Umami. body: - type: textarea @@ -22,6 +22,10 @@ body: label: Relevant log output description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. render: shell + - type: input + attributes: + label: Which Umami version are you using? (if relevant) + description: 'For example: Chrome, Edge, Firefox, etc' - type: input attributes: label: Which browser are you using? (if relevant) @@ -29,4 +33,4 @@ body: - type: input attributes: label: How are you deploying your application? (if relevant) - description: 'For example: Vercel, Railway, Docker, etc' \ No newline at end of file + description: 'For example: Vercel, Railway, Docker, etc' diff --git a/.github/workflows/cd-cloud.yml b/.github/workflows/cd-cloud.yml new file mode 100644 index 000000000..386a6ce03 --- /dev/null +++ b/.github/workflows/cd-cloud.yml @@ -0,0 +1,28 @@ +name: Create docker images + +on: + push: + branches: + - analytics + +jobs: + build: + name: Build, push, and deploy + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Generate random hash + id: random_hash + run: echo "hash=$(openssl rand -hex 4)" >> $GITHUB_OUTPUT + + - uses: mr-smithers-excellent/docker-build-push@v6 + name: Build & push Docker image to docker.io + with: + image: umamisoftware/umami + tags: cloud-${{ steps.random_hash.outputs.hash }}, cloud-latest + buildArgs: DATABASE_TYPE=postgresql + registry: docker.io + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} diff --git a/.github/workflows/cd-manual.yml b/.github/workflows/cd-manual.yml index ac701fcc5..1f8651fa2 100644 --- a/.github/workflows/cd-manual.yml +++ b/.github/workflows/cd-manual.yml @@ -20,11 +20,26 @@ jobs: steps: - uses: actions/checkout@v3 + - name: Extract version parts from input + id: extract_version + run: | + echo "version=$(echo ${{ github.event.inputs.version }})" >> $GITHUB_ENV + echo "major=$(echo ${{ github.event.inputs.version }} | cut -d. -f1)" >> $GITHUB_ENV + echo "minor=$(echo ${{ github.event.inputs.version }} | cut -d. -f2)" >> $GITHUB_ENV + + - name: Generate tags + id: generate_tags + run: | + echo "tag_major=$(echo ${{ matrix.db-type }}-${{ env.major }})" >> $GITHUB_ENV + echo "tag_minor=$(echo ${{ matrix.db-type }}-${{ env.major }}.${{ env.minor }})" >> $GITHUB_ENV + echo "tag_patch=$(echo ${{ matrix.db-type }}-${{ env.version }})" >> $GITHUB_ENV + echo "tag_latest=$(echo ${{ matrix.db-type }}-latest)" >> $GITHUB_ENV + - uses: mr-smithers-excellent/docker-build-push@v6 name: Build & push Docker image to ghcr.io for ${{ matrix.db-type }} with: image: umami - tags: ${{ matrix.db-type }}-${{ inputs.version }}, ${{ matrix.db-type }}-latest + tags: ${{ env.tag_major }}, ${{ env.tag_minor }}, ${{ env.tag_patch }}, ${{ env.tag_latest }} buildArgs: DATABASE_TYPE=${{ matrix.db-type }} registry: ghcr.io multiPlatform: true @@ -36,7 +51,7 @@ jobs: name: Build & push Docker image to docker.io for ${{ matrix.db-type }} with: image: umamisoftware/umami - tags: ${{ matrix.db-type }}-${{ inputs.version }}, ${{ matrix.db-type }}-latest + tags: ${{ env.tag_major }}, ${{ env.tag_minor }}, ${{ env.tag_patch }}, ${{ env.tag_latest }} buildArgs: DATABASE_TYPE=${{ matrix.db-type }} registry: docker.io username: ${{ secrets.DOCKER_USERNAME }} diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 0660bcbaa..f67f51c38 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -16,13 +16,22 @@ jobs: - uses: actions/checkout@v3 - name: Set env - run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV + run: | + echo "NOW=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV + + - name: Generate tags + id: generate_tags + run: | + echo "tag_patch=$(echo ${{ matrix.db-type }})-${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + echo "tag_minor=$(echo ${{ matrix.db-type }})-$(echo ${GITHUB_REF#refs/tags/} | cut -d. -f1,2)" >> $GITHUB_ENV + echo "tag_major=$(echo ${{ matrix.db-type }})-$(echo ${GITHUB_REF#refs/tags/} | cut -d. -f1)" >> $GITHUB_ENV + echo "tag_latest=$(echo ${{ matrix.db-type }})-latest" >> $GITHUB_ENV - uses: mr-smithers-excellent/docker-build-push@v6 name: Build & push Docker image to ghcr.io for ${{ matrix.db-type }} with: image: umami - tags: ${{ matrix.db-type }}-${{ env.RELEASE_VERSION }}, ${{ matrix.db-type }}-latest + tags: ${{ env.tag_major }}, ${{ env.tag_minor }}, ${{ env.tag_patch }}, ${{ env.tag_latest }} buildArgs: DATABASE_TYPE=${{ matrix.db-type }} registry: ghcr.io multiPlatform: true @@ -34,7 +43,7 @@ jobs: name: Build & push Docker image to docker.io for ${{ matrix.db-type }} with: image: umamisoftware/umami - tags: ${{ matrix.db-type }}-${{ env.RELEASE_VERSION }}, ${{ matrix.db-type }}-latest + tags: ${{ env.tag_major }}, ${{ env.tag_minor }}, ${{ env.tag_patch }}, ${{ env.tag_latest }} buildArgs: DATABASE_TYPE=${{ matrix.db-type }} registry: docker.io username: ${{ secrets.DOCKER_USERNAME }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66e16a03e..314c6944b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,20 +16,21 @@ jobs: strategy: matrix: include: - - node-version: 18.x + - node-version: 18.18 db-type: postgresql - - node-version: 18.x + - node-version: 18.18 db-type: mysql steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - cache: 'npm' + cache: 'yarn' env: DATABASE_TYPE: ${{ matrix.db-type }} - run: npm install --global yarn - - run: yarn install --frozen-lockfile + - run: yarn install + - run: yarn test - run: yarn build diff --git a/.gitignore b/.gitignore index 050397c90..b11f45093 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ node_modules # misc .DS_Store .idea +.yarn *.iml *.log .vscode @@ -35,6 +36,7 @@ yarn-error.log* # local env files .env .env.* +*.env.* *.dev.yml diff --git a/.stylelintrc.json b/.stylelintrc.json index 9a05af144..b940b63e4 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -5,13 +5,7 @@ "stylelint-config-prettier" ], "rules": { - "no-descending-specificity": null, - "selector-pseudo-class-no-unknown": [ - true, - { - "ignorePseudoClasses": ["global", "horizontal", "vertical"] - } - ] + "no-descending-specificity": null }, "ignoreFiles": ["**/*.js", "**/*.md"] } diff --git a/Dockerfile b/Dockerfile index 801b2bc20..824c16db0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,8 +36,8 @@ RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs RUN set -x \ - && apk add --no-cache curl \ - && yarn add npm-run-all dotenv prisma semver + && apk add --no-cache curl openssl \ + && yarn add npm-run-all dotenv semver prisma@5.17.0 # You only need to copy next.config.js if you are NOT using the default configuration COPY --from=builder /app/next.config.js . diff --git a/README.md b/README.md index 0f76e5635..b13784283 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,93 @@ -# umami +
+
+
+ Umami is a simple, fast, privacy-focused alternative to Google Analytics. +
-A detailed getting started guide can be found at [https://umami.is/docs/](https://umami.is/docs/) + -## Installing from source +--- + +## 🚀 Getting Started + +A detailed getting started guide can be found at [umami.is/docs](https://umami.is/docs/). + +--- + +## 🛠 Installing from Source ### Requirements -- A server with Node.js version 16.13 or newer -- A database. Umami supports [MySQL](https://www.mysql.com/) and [Postgresql](https://www.postgresql.org/) databases. +- A server with Node.js version 18.18 or newer +- A database. Umami supports [MariaDB](https://www.mariadb.org/) (minimum v10.5), [MySQL](https://www.mysql.com/) (minimum v8.0) and [PostgreSQL](https://www.postgresql.org/) (minimum v12.14) databases. ### Install Yarn -``` +```bash npm install -g yarn ``` -### Get the source code and install packages +### Get the Source Code and Install Packages -``` +```bash git clone https://github.com/umami-software/umami.git cd umami yarn install ``` -### Configure umami +### Configure Umami -Create an `.env` file with the following +Create an `.env` file with the following: -``` +```bash DATABASE_URL=connection-url ``` -The connection url is in the following format: +The connection URL format: -``` +```bash postgresql://username:mypassword@localhost:5432/mydb - mysql://username:mypassword@localhost:3306/mydb ``` -### Build the application +### Build the Application ```bash yarn build ``` -The build step will also create tables in your database if you ae installing for the first time. It will also create a login user with username **admin** and password **umami**. +*The build step will create tables in your database if you are installing for the first time. It will also create a login user with username **admin** and password **umami**.* -### Start the application +### Start the Application ```bash yarn start ``` -By default this will launch the application on `http://localhost:3000`. You will need to either -[proxy](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) requests from your web server -or change the [port](https://nextjs.org/docs/api-reference/cli#production) to serve the application directly. +*By default, this will launch the application on `http://localhost:3000`. You will need to either [proxy](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) requests from your web server or change the [port](https://nextjs.org/docs/api-reference/cli#production) to serve the application directly.* -## Installing with Docker +--- -To build the umami container and start up a Postgres database, run: +## 🐳 Installing with Docker + +To build the Umami container and start up a Postgres database, run: ```bash docker compose up -d @@ -72,16 +96,18 @@ docker compose up -d Alternatively, to pull just the Umami Docker image with PostgreSQL support: ```bash -docker pull ghcr.io/umami-software/umami:postgresql-latest +docker pull docker.umami.is/umami-software/umami:postgresql-latest ``` Or with MySQL support: ```bash -docker pull ghcr.io/umami-software/umami:mysql-latest +docker pull docker.umami.is/umami-software/umami:mysql-latest ``` -## Getting updates +--- + +## 🔄 Getting Updates To get the latest features, simply do a pull, install any new dependencies, and rebuild: @@ -98,6 +124,36 @@ docker compose pull docker compose up --force-recreate ``` -## License +--- -MIT +## 🛟 Support + + + +[release-shield]: https://img.shields.io/github/release/umami-software/umami.svg +[releases-url]: https://github.com/umami-software/umami/releases +[license-shield]: https://img.shields.io/github/license/umami-software/umami.svg +[license-url]: https://github.com/umami-software/umami/blob/master/LICENSE +[build-shield]: https://img.shields.io/github/actions/workflow/status/umami-software/umami/ci.yml +[build-url]: https://github.com/umami-software/umami/actions +[github-shield]: https://img.shields.io/badge/GitHub--blue?style=social&logo=github +[github-url]: https://github.com/umami-software/umami +[twitter-shield]: https://img.shields.io/badge/Twitter--blue?style=social&logo=twitter +[twitter-url]: https://twitter.com/umami_software +[linkedin-shield]: https://img.shields.io/badge/LinkedIn--blue?style=social&logo=linkedin +[linkedin-url]: https://linkedin.com/company/umami-software +[discord-shield]: https://img.shields.io/badge/Discord--blue?style=social&logo=discord +[discord-url]: https://discord.com/invite/4dz4zcXYrQ diff --git a/cypress.config.ts b/cypress.config.ts new file mode 100644 index 000000000..4b01931b4 --- /dev/null +++ b/cypress.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'cypress'; + +export default defineConfig({ + e2e: { + baseUrl: 'http://localhost:3000', + }, + // default username / password on init + env: { + umami_user: 'admin', + umami_password: 'umami', + }, +}); diff --git a/cypress/docker-compose.yml b/cypress/docker-compose.yml new file mode 100644 index 000000000..01a47bd8c --- /dev/null +++ b/cypress/docker-compose.yml @@ -0,0 +1,52 @@ +--- +version: '3' +services: + umami: + build: ../ + #image: ghcr.io/umami-software/umami:postgresql-latest + ports: + - '3000:3000' + environment: + DATABASE_URL: postgresql://umami:umami@db:5432/umami + DATABASE_TYPE: postgresql + APP_SECRET: replace-me-with-a-random-string + depends_on: + db: + condition: service_healthy + restart: always + healthcheck: + test: ['CMD-SHELL', 'curl http://localhost:3000/api/heartbeat'] + interval: 5s + timeout: 5s + retries: 5 + db: + image: postgres:15-alpine + environment: + POSTGRES_DB: umami + POSTGRES_USER: umami + POSTGRES_PASSWORD: umami + volumes: + - umami-db-data:/var/lib/postgresql/data + restart: always + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}'] + interval: 5s + timeout: 5s + retries: 5 + cypress: + image: 'cypress/included:13.6.0' + depends_on: + - umami + - db + environment: + - CYPRESS_baseUrl=http://umami:3000 + - CYPRESS_umami_user=admin + - CYPRESS_umami_password=umami + volumes: + - ./tsconfig.json:/tsconfig.json + - ../cypress.config.ts:/cypress.config.ts + - ./:/cypress + - ../node_modules/:/node_modules + - ../src/lib/crypto.ts:/src/lib/crypto.ts +volumes: + umami-db-data: diff --git a/cypress/e2e/login.cy.ts b/cypress/e2e/login.cy.ts new file mode 100644 index 000000000..5831c81d6 --- /dev/null +++ b/cypress/e2e/login.cy.ts @@ -0,0 +1,22 @@ +describe('Login tests', () => { + it( + 'logs user in with correct credentials and logs user out', + { + defaultCommandTimeout: 10000, + }, + () => { + cy.visit('/login'); + cy.getDataTest('input-username').find('input').click(); + cy.getDataTest('input-username').find('input').type(Cypress.env('umami_user'), { delay: 50 }); + cy.getDataTest('input-password').find('input').click(); + cy.getDataTest('input-password') + .find('input') + .type(Cypress.env('umami_password'), { delay: 50 }); + cy.getDataTest('button-submit').click(); + cy.url().should('eq', Cypress.config().baseUrl + '/dashboard'); + cy.getDataTest('button-profile').click(); + cy.getDataTest('item-logout').click(); + cy.url().should('eq', Cypress.config().baseUrl + '/login'); + }, + ); +}); diff --git a/cypress/e2e/website.cy.ts b/cypress/e2e/website.cy.ts new file mode 100644 index 000000000..b60d8e7a4 --- /dev/null +++ b/cypress/e2e/website.cy.ts @@ -0,0 +1,89 @@ +describe('Website tests', () => { + Cypress.session.clearAllSavedSessions(); + + beforeEach(() => { + cy.login(Cypress.env('umami_user'), Cypress.env('umami_password')); + }); + + it('Add a website', () => { + // add website + cy.visit('/settings/websites'); + cy.getDataTest('button-website-add').click(); + cy.contains(/Add website/i).should('be.visible'); + cy.getDataTest('input-name').find('input').click(); + cy.getDataTest('input-name').find('input').type('Add test', { delay: 50 }); + cy.getDataTest('input-domain').find('input').click(); + cy.getDataTest('input-domain').find('input').type('addtest.com', { delay: 50 }); + cy.getDataTest('button-submit').click(); + cy.get('td[label="Name"]').should('contain.text', 'Add test'); + cy.get('td[label="Domain"]').should('contain.text', 'addtest.com'); + + // clean-up data + cy.getDataTest('link-button-edit').first().click(); + cy.contains(/Details/i).should('be.visible'); + cy.getDataTest('text-field-websiteId') + .find('input') + .then($input => { + const websiteId = $input[0].value; + cy.deleteWebsite(websiteId); + }); + cy.visit('/settings/websites'); + cy.contains(/Add test/i).should('not.exist'); + }); + + it('Edit a website', () => { + // prep data + cy.addWebsite('Update test', 'updatetest.com'); + cy.visit('/settings/websites'); + + // edit website + cy.getDataTest('link-button-edit').first().click(); + cy.contains(/Details/i).should('be.visible'); + cy.getDataTest('input-name').find('input').click(); + cy.getDataTest('input-name').find('input').clear(); + cy.getDataTest('input-name').find('input').type('Updated website', { delay: 50 }); + cy.getDataTest('input-domain').find('input').click(); + cy.getDataTest('input-domain').find('input').clear(); + cy.getDataTest('input-domain').find('input').type('updatedwebsite.com', { delay: 50 }); + cy.getDataTest('button-submit').click({ force: true }); + cy.getDataTest('input-name').find('input').should('have.value', 'Updated website'); + cy.getDataTest('input-domain').find('input').should('have.value', 'updatedwebsite.com'); + + // verify tracking script + cy.get('div') + .contains(/Tracking code/i) + .click(); + cy.get('textarea').should('contain.text', Cypress.config().baseUrl + '/script.js'); + + // clean-up data + cy.get('div') + .contains(/Details/i) + .click(); + cy.contains(/Details/i).should('be.visible'); + cy.getDataTest('text-field-websiteId') + .find('input') + .then($input => { + const websiteId = $input[0].value; + cy.deleteWebsite(websiteId); + }); + cy.visit('/settings/websites'); + cy.contains(/Add test/i).should('not.exist'); + }); + + it('Delete a website', () => { + // prep data + cy.addWebsite('Delete test', 'deletetest.com'); + cy.visit('/settings/websites'); + + // delete website + cy.getDataTest('link-button-edit').first().click(); + cy.contains(/Data/i).should('be.visible'); + cy.get('div').contains(/Data/i).click(); + cy.contains(/All website data will be deleted./i).should('be.visible'); + cy.getDataTest('button-delete').click(); + cy.contains(/Type DELETE in the box below to confirm./i).should('be.visible'); + cy.get('input[name="confirm"').type('DELETE'); + cy.get('button[type="submit"]').click(); + cy.contains(/Delete test/i).should('not.exist'); + }); +}); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts new file mode 100644 index 000000000..2c45142b3 --- /dev/null +++ b/cypress/support/e2e.ts @@ -0,0 +1,57 @@ +///