diff --git a/.github/workflows/cd-manual.yml b/.github/workflows/cd-manual.yml new file mode 100644 index 000000000..df6aa6289 --- /dev/null +++ b/.github/workflows/cd-manual.yml @@ -0,0 +1,58 @@ +name: Create docker images (manual) + +on: + workflow_dispatch: + inputs: + version: + type: string + description: Version + required: true + +jobs: + build: + name: Build, push, and deploy + runs-on: ubuntu-latest + + strategy: + matrix: + db-type: [postgresql] + + 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: ${{ env.tag_major }}, ${{ env.tag_minor }}, ${{ env.tag_patch }}, ${{ env.tag_latest }} + buildArgs: DATABASE_TYPE=${{ matrix.db-type }} + registry: ghcr.io + multiPlatform: true + platform: linux/amd64,linux/arm64 + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: mr-smithers-excellent/docker-build-push@v6 + name: Build & push Docker image to docker.io for ${{ matrix.db-type }} + with: + image: umamisoftware/umami + 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 }} + password: ${{ secrets.DOCKER_PASSWORD }} diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index a9509bce0..a02e9900c 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -5,11 +5,6 @@ on: tags: - 'v*.*.*' workflow_dispatch: - inputs: - version: - description: 'Optional image version (e.g. 3.0.0, v3.0.0, or 3.0.0-beta.1)' - required: false - default: '' jobs: build: @@ -18,20 +13,22 @@ jobs: permissions: contents: read packages: write + id-token: write + + strategy: + matrix: + db-type: [postgresql] steps: - uses: actions/checkout@v5 + # Install cosign (for image signing) + - name: Install cosign + uses: sigstore/cosign-installer@v3 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Log into GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Log into Docker Hub if: github.repository == 'umami-software/umami' uses: docker/login-action@v3 @@ -40,61 +37,44 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - - name: Compute version tags - id: compute - run: | - INPUT="${{ github.event.inputs.version }}" - REF_TYPE="${{ github.ref_type }}" - REF_NAME="${{ github.ref_name }}" + - name: Log into GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - # Determine version source - if [[ -n "$INPUT" ]]; then - VERSION="${INPUT#v}" - elif [[ "$REF_TYPE" == "tag" ]]; then - VERSION="${REF_NAME#v}" - else - VERSION="" - fi - - TAGS="" - - if [[ -n "$VERSION" ]]; then - MAJOR=$(echo "$VERSION" | cut -d. -f1) - MINOR=$(echo "$VERSION" | cut -d. -f2) - - if [[ "$VERSION" == *-* ]]; then - # prerelease: only version tag - TAGS="$VERSION" - else - # stable release: version + hierarchy + latest - TAGS="$VERSION,${MAJOR}.${MINOR},${MAJOR},postgresql-latest,latest" - fi - else - # Non-tag build (e.g. from main branch) - TAGS="${REF_NAME}" - fi - - echo "tags=$TAGS" >> $GITHUB_OUTPUT - echo "Computed tags: $TAGS" + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: | + umamisoftware/umami,enable=${{ github.repository == 'umami-software/umami' }} + ghcr.io/${{ github.repository }} + flavor: | + latest=auto + prefix=${{ matrix.db-type }}- + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} - name: Build and push Docker image - run: | - TAGS="${{ steps.compute.outputs.tags }}" + id: build-and-push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + build-args: DATABASE_TYPE=${{ matrix.db-type }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max - # Set image targets conditionally - if [[ "${{ github.repository }}" == "umami-software/umami" ]]; then - IMAGES=("umamisoftware/umami" "ghcr.io/${{ github.repository }}") - else - IMAGES=("ghcr.io/${{ github.repository }}") - fi - - for IMAGE in "${IMAGES[@]}"; do - echo "Building and pushing $IMAGE with tags: $TAGS" - docker buildx build \ - --platform linux/amd64,linux/arm64 \ - --push \ - $(echo "$TAGS" | tr ',' '\n' | sed "s|^|--tag ${IMAGE}:|") \ - --cache-from type=gha \ - --cache-to type=gha,mode=max \ - . - done + # Sign the published image digest + - name: Sign the published Docker image + env: + TAGS: ${{ steps.meta.outputs.tags }} + DIGEST: ${{ steps.build-and-push.outputs.digest }} + run: echo "${TAGS}" | xargs -I {} cosign sign --yes "{}@${DIGEST}" diff --git a/.github/workflows/delete-untagged-images.yml b/.github/workflows/delete-untagged-images.yml new file mode 100644 index 000000000..a23a1bd27 --- /dev/null +++ b/.github/workflows/delete-untagged-images.yml @@ -0,0 +1,22 @@ +name: Delete untagged GHCR images + +on: + workflow_dispatch: # Run manually from the Actions tab + +jobs: + cleanup: + name: Delete all untagged images + runs-on: ubuntu-latest + + permissions: + packages: write + contents: read + + steps: + - name: Delete untagged GHCR images + uses: actions/delete-package-versions@v5 + with: + package-name: "umami" # 👈 change if your GHCR package name differs + package-type: "container" + delete-only-untagged-versions: true + min-versions-to-keep: 0 diff --git a/README.md b/README.md index d3791e269..6d166d8c8 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ docker compose up -d Alternatively, to pull just the Umami Docker image with PostgreSQL support: ```bash -docker pull docker.umami.is/umami-software/umami:latest +docker pull docker.umami.is/umami-software/umami:postgresql-latest ``` --- diff --git a/docker-compose.yml b/docker-compose.yml index 8c8a47a6e..7b51db66c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ --- services: umami: - image: ghcr.io/umami-software/umami:latest + image: ghcr.io/umami-software/umami:postgresql-latest ports: - "3000:3000" environment: diff --git a/src/app/(main)/App.tsx b/src/app/(main)/App.tsx index ec08838d1..32218d115 100644 --- a/src/app/(main)/App.tsx +++ b/src/app/(main)/App.tsx @@ -9,14 +9,18 @@ import { MobileNav } from '@/app/(main)/MobileNav'; export function App({ children }) { const { user, isLoading, error } = useLoginQuery(); const config = useConfig(); - const { pathname } = useNavigation(); + const { pathname, router } = useNavigation(); if (isLoading || !config) { return ; } if (error) { - window.location.href = `${process.env.basePath || ''}/login`; + if (process.env.cloudMode) { + window.location.href = '/login'; + } else { + router.push('/login'); + } return null; } diff --git a/src/app/(main)/UpdateNotice.tsx b/src/app/(main)/UpdateNotice.tsx index 81e2ca3af..357287912 100644 --- a/src/app/(main)/UpdateNotice.tsx +++ b/src/app/(main)/UpdateNotice.tsx @@ -1,5 +1,5 @@ import { useEffect, useCallback, useState } from 'react'; -import { Button, AlertBanner, Column, Row } from '@umami/react-zen'; +import { Button, AlertBanner, Flexbox } from '@umami/react-zen'; import { setItem } from '@/lib/storage'; import { useVersion, checkVersion } from '@/store/version'; import { REPO_URL, VERSION_CHECK } from '@/lib/constants'; @@ -47,15 +47,13 @@ export function UpdateNotice({ user, config }) { } return ( - - - - - - - - + + + + + + ); } diff --git a/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx b/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx index ea0edde1b..e9e3e6a0c 100644 --- a/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx +++ b/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx @@ -1,24 +1,11 @@ -import { - DataTable, - DataColumn, - Row, - Text, - DataTableProps, - IconLabel, - Button, - Dialog, - DialogTrigger, - Icon, - Popover, -} from '@umami/react-zen'; +import { DataTable, DataColumn, Row, Text, DataTableProps, IconLabel } from '@umami/react-zen'; import { useFormat, useMessages, useNavigation } from '@/components/hooks'; import { Avatar } from '@/components/common/Avatar'; import Link from 'next/link'; -import { Eye, FileText } from '@/components/icons'; +import { Eye } from '@/components/icons'; import { Lightning } from '@/components/svg'; import { DateDistance } from '@/components/common/DateDistance'; import { TypeIcon } from '@/components/common/TypeIcon'; -import { EventData } from '@/components/metrics/EventData'; export function EventsTable(props: DataTableProps) { const { formatMessage, labels } = useMessages(); @@ -45,7 +32,6 @@ export function EventsTable(props: DataTableProps) { > {row.eventName || row.urlPath} - {row.hasData > 0 && } ); }} @@ -86,22 +72,3 @@ export function EventsTable(props: DataTableProps) { ); } - -const PropertiesButton = props => { - return ( - - - - - - - - - ); -}; diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx index 3dec340f2..9ae19bf89 100644 --- a/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx +++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx @@ -9,7 +9,6 @@ import { useCountryNames, useLocale, useMessages, - useMobile, useNavigation, useTimezone, useWebsite, @@ -41,7 +40,6 @@ export function RealtimeLog({ data }: { data: any }) { const { countryNames } = useCountryNames(locale); const [filter, setFilter] = useState(TYPE_ALL); const { updateParams } = useNavigation(); - const { isPhone } = useMobile(); const buttons = [ { @@ -125,18 +123,12 @@ export function RealtimeLog({ data }: { data: any }) { const row = logs[index]; return ( - - - - - - - {getTime(row)} - + + + + {getTime(row)} - - {getDetail(row)} - + {getDetail(row)} ); @@ -176,22 +168,10 @@ export function RealtimeLog({ data }: { data: any }) { return ( {formatMessage(labels.activity)} - {isPhone ? ( - <> - - - - - - - - ) : ( - - - - - )} - + + + + {logs?.length === 0 && } {logs?.length > 0 && ( diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx index 0f9fa358b..7f9ab6085 100644 --- a/src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx +++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx @@ -6,7 +6,7 @@ import { PageBody } from '@/components/common/PageBody'; import { Panel } from '@/components/common/Panel'; import { RealtimeChart } from '@/components/metrics/RealtimeChart'; import { WorldMap } from '@/components/metrics/WorldMap'; -import { useMobile, useRealtimeQuery } from '@/components/hooks'; +import { useRealtimeQuery } from '@/components/hooks'; import { RealtimeLog } from './RealtimeLog'; import { RealtimeHeader } from './RealtimeHeader'; import { RealtimePaths } from './RealtimePaths'; @@ -16,7 +16,6 @@ import { percentFilter } from '@/lib/filters'; export function RealtimePage({ websiteId }: { websiteId: string }) { const { data, isLoading, error } = useRealtimeQuery(websiteId); - const { isMobile } = useMobile(); if (isLoading || error) { return ; @@ -49,7 +48,7 @@ export function RealtimePage({ websiteId }: { websiteId: string }) { - + diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx index 7bcf1b760..b9f34e485 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx @@ -14,7 +14,7 @@ import { import { LoadingPanel } from '@/components/common/LoadingPanel'; import { Eye, FileText } from '@/components/icons'; import { Lightning } from '@/components/svg'; -import { useMessages, useMobile, useSessionActivityQuery, useTimezone } from '@/components/hooks'; +import { useMessages, useSessionActivityQuery, useTimezone } from '@/components/hooks'; import { EventData } from '@/components/metrics/EventData'; export function SessionActivity({ @@ -36,7 +36,6 @@ export function SessionActivity({ startDate, endDate, ); - const { isMobile } = useMobile(); let lastDay = null; return ( @@ -51,16 +50,16 @@ export function SessionActivity({ {showHeader && {formatTimezoneDate(createdAt, 'PPPP')}} - {formatTimezoneDate(createdAt, 'pp')} + {formatTimezoneDate(createdAt, 'pp')} {eventName ? : } - + {eventName ? formatMessage(labels.triggeredEvent) : formatMessage(labels.viewedPage)} - + {eventName || urlPath} {hasData > 0 && } diff --git a/src/app/logout/LogoutPage.tsx b/src/app/logout/LogoutPage.tsx index d66d62a98..909f35de3 100644 --- a/src/app/logout/LogoutPage.tsx +++ b/src/app/logout/LogoutPage.tsx @@ -13,7 +13,7 @@ export function LogoutPage() { async function logout() { await post('/auth/logout'); - window.location.href = `${process.env.basePath || ''}/login`; + router.push('/login'); } removeClientAuthToken(); diff --git a/src/components/metrics/EventsChart.tsx b/src/components/metrics/EventsChart.tsx index 246772b3e..7301faf44 100644 --- a/src/components/metrics/EventsChart.tsx +++ b/src/components/metrics/EventsChart.tsx @@ -1,11 +1,10 @@ +import { useMemo, useState, useEffect } from 'react'; +import { colord } from 'colord'; import { BarChart, BarChartProps } from '@/components/charts/BarChart'; -import { LoadingPanel } from '@/components/common/LoadingPanel'; import { useDateRange, useLocale, useWebsiteEventsSeriesQuery } from '@/components/hooks'; import { renderDateLabels } from '@/lib/charts'; import { CHART_COLORS } from '@/lib/constants'; -import { generateTimeSeries } from '@/lib/date'; -import { colord } from 'colord'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; export interface EventsChartProps extends BarChartProps { websiteId: string; @@ -16,7 +15,7 @@ export function EventsChart({ websiteId, focusLabel }: EventsChartProps) { const { dateRange: { startDate, endDate, unit }, } = useDateRange(); - const { locale, dateLocale } = useLocale(); + const { locale } = useLocale(); const { data, isLoading, error } = useWebsiteEventsSeriesQuery(websiteId); const [label, setLabel] = useState(focusLabel); @@ -38,7 +37,7 @@ export function EventsChart({ websiteId, focusLabel }: EventsChartProps) { const color = colord(CHART_COLORS[index % CHART_COLORS.length]); return { label: key, - data: generateTimeSeries(map[key], startDate, endDate, unit, dateLocale), + data: map[key], lineTension: 0, backgroundColor: color.alpha(0.6).toRgbString(), borderColor: color.alpha(0.7).toRgbString(), @@ -55,8 +54,6 @@ export function EventsChart({ websiteId, focusLabel }: EventsChartProps) { } }, [focusLabel]); - const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]); - return ( {chartData && ( @@ -66,7 +63,7 @@ export function EventsChart({ websiteId, focusLabel }: EventsChartProps) { maxDate={endDate} unit={unit} stacked={true} - renderXLabel={renderXLabel} + renderXLabel={renderDateLabels(unit, locale)} height="400px" /> )} diff --git a/src/components/metrics/ListTable.tsx b/src/components/metrics/ListTable.tsx index e76e01745..303556b01 100644 --- a/src/components/metrics/ListTable.tsx +++ b/src/components/metrics/ListTable.tsx @@ -57,7 +57,7 @@ export function ListTable({ showPercentage={showPercentage} change={renderChange ? renderChange(row, index) : null} currency={currency} - isPhone={isPhone} + isMobile={isPhone} /> ); }; @@ -101,7 +101,7 @@ const AnimatedRow = ({ animate, showPercentage = true, currency, - isPhone, + isMobile, }) => { const props = useSpring({ width: percent, @@ -120,7 +120,7 @@ const AnimatedRow = ({ gap > - + {label} diff --git a/src/lib/__tests__/detect.test.ts b/src/lib/__tests__/detect.test.ts index f02ac8392..fcf706af1 100644 --- a/src/lib/__tests__/detect.test.ts +++ b/src/lib/__tests__/detect.test.ts @@ -1,4 +1,4 @@ -import { getIpAddress } from '../ip'; +import * as detect from '../detect'; const IP = '127.0.0.1'; const BAD_IP = '127.127.127.127'; @@ -6,23 +6,23 @@ const BAD_IP = '127.127.127.127'; test('getIpAddress: Custom header', () => { process.env.CLIENT_IP_HEADER = 'x-custom-ip-header'; - expect(getIpAddress(new Headers({ 'x-custom-ip-header': IP }))).toEqual(IP); + expect(detect.getIpAddress(new Headers({ 'x-custom-ip-header': IP }))).toEqual(IP); }); test('getIpAddress: CloudFlare header', () => { - expect(getIpAddress(new Headers({ 'cf-connecting-ip': IP }))).toEqual(IP); + expect(detect.getIpAddress(new Headers({ 'cf-connecting-ip': IP }))).toEqual(IP); }); test('getIpAddress: Standard header', () => { - expect(getIpAddress(new Headers({ 'x-forwarded-for': IP }))).toEqual(IP); + expect(detect.getIpAddress(new Headers({ 'x-forwarded-for': IP }))).toEqual(IP); }); test('getIpAddress: CloudFlare header is lower priority than standard header', () => { - expect(getIpAddress(new Headers({ 'cf-connecting-ip': BAD_IP, 'x-forwarded-for': IP }))).toEqual( - IP, - ); + expect( + detect.getIpAddress(new Headers({ 'cf-connecting-ip': BAD_IP, 'x-forwarded-for': IP })), + ).toEqual(IP); }); test('getIpAddress: No header', () => { - expect(getIpAddress(new Headers())).toEqual(null); + expect(detect.getIpAddress(new Headers())).toEqual(null); }); diff --git a/src/queries/sql/events/getEventData.ts b/src/queries/sql/events/getEventData.ts index 269258a8d..42dc2040b 100644 --- a/src/queries/sql/events/getEventData.ts +++ b/src/queries/sql/events/getEventData.ts @@ -19,20 +19,20 @@ async function relationalQuery(websiteId: string, eventId: string) { return rawQuery( ` - select event_data.website_id as "websiteId", - event_data.website_event_id as "eventId", - website_event.event_name as "eventName", - event_data.data_key as "dataKey", - event_data.string_value as "stringValue", - event_data.number_value as "numberValue", - event_data.date_value as "dateValue", - event_data.data_type as "dataType", - event_data.created_at as "createdAt" + select website_id as "websiteId", + session_id as "sessionId", + event_id as "eventId", + url_path as "urlPath", + event_name as "eventName", + data_key as "dataKey", + string_value as "stringValue", + number_value as "numberValue", + date_value as "dateValue", + data_type as "dataType", + created_at as "createdAt" from event_data - join website_event on website_event.event_id = event_data.website_event_id - and website_event.website_id = {{websiteId::uuid}} - where event_data.website_id = {{websiteId::uuid}} - and event_data.website_event_id = {{eventId::uuid}} + website_id = {{websiteId::uuid}} + event_id = {{eventId::uuid}} `, { websiteId, eventId }, FUNCTION_NAME, @@ -45,7 +45,9 @@ async function clickhouseQuery(websiteId: string, eventId: string): Promise result?.[0]); - total.average = total.count > 0 ? Number(total.sum) / Number(total.count) : 0; + total.average = total.count > 0 ? total.sum / total.count : 0; return { chart, country, total }; } diff --git a/src/queries/sql/sessions/getSessionActivity.ts b/src/queries/sql/sessions/getSessionActivity.ts index 3dd4fa9d8..360db530a 100644 --- a/src/queries/sql/sessions/getSessionActivity.ts +++ b/src/queries/sql/sessions/getSessionActivity.ts @@ -29,10 +29,10 @@ async function relationalQuery(websiteId: string, sessionId: string, filters: Qu event_type as "eventType", event_name as "eventName", visit_id as "visitId", - event_id IN (select website_event_id + event_id IN (select event_id from event_data where website_id = {{websiteId::uuid}} - and created_at between {{startDate}} and {{endDate}}) AS "hasData" + and session_id = {{sessionId::uuid}}) AS "hasData" from website_event where website_id = {{websiteId::uuid}} and session_id = {{sessionId::uuid}}